mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-18 01:47:25 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -184,7 +184,7 @@ body {
|
||||
font-family: 'Maple Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: no-wrap;
|
||||
white-space: break-spaces;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<template v-else-if="medal.type === 'POST'">
|
||||
{{ medal.currentPostCount }}/{{ medal.targetPostCount }}
|
||||
</template>
|
||||
<template v-else-if="medal.type === 'FEATURED'">
|
||||
{{ medal.currentFeaturedCount }}/{{ medal.targetFeaturedCount }}
|
||||
</template>
|
||||
<template v-else-if="medal.type === 'CONTRIBUTOR'">
|
||||
{{ medal.currentContributionLines }}/{{ medal.targetContributionLines }}
|
||||
</template>
|
||||
|
||||
65
frontend_nuxt/components/BaseSwitch.vue
Normal file
65
frontend_nuxt/components/BaseSwitch.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
@@ -50,7 +50,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const checkMilkTeaActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
@@ -68,7 +68,7 @@ const checkMilkTeaActivity = async () => {
|
||||
}
|
||||
|
||||
const checkInviteCodeActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (localStorage.getItem('inviteCodeActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
@@ -86,32 +86,30 @@ const checkInviteCodeActivity = async () => {
|
||||
}
|
||||
|
||||
const closeInviteCodePopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
|
||||
showInviteCodePopup.value = false
|
||||
}
|
||||
|
||||
const closeMilkTeaPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
showMilkTeaPopup.value = false
|
||||
checkNotificationSetting()
|
||||
}
|
||||
|
||||
const checkNotificationSetting = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (!authState.loggedIn) return
|
||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||
showNotificationPopup.value = true
|
||||
}
|
||||
const closeNotificationPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('notificationSettingPopupShown', 'true')
|
||||
showNotificationPopup.value = false
|
||||
checkNewMedals()
|
||||
}
|
||||
const checkNewMedals = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (!authState.loggedIn || !authState.userId) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
|
||||
@@ -129,7 +127,7 @@ const checkNewMedals = async () => {
|
||||
}
|
||||
}
|
||||
const closeMedalPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||
newMedals.value.forEach((m) => seen.add(m.type))
|
||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||
|
||||
@@ -40,7 +40,7 @@ const stopObserver = () => {
|
||||
}
|
||||
|
||||
const startObserver = () => {
|
||||
if (!process.client || props.pause || done.value) return
|
||||
if (!import.meta.client || props.pause || done.value) return
|
||||
stopObserver()
|
||||
io = new IntersectionObserver(
|
||||
async (entries) => {
|
||||
|
||||
@@ -63,7 +63,7 @@ const isImageIcon = (icon) => {
|
||||
}
|
||||
|
||||
const buildTagsUrl = (kw = '') => {
|
||||
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
||||
const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
|
||||
const url = new URL('/api/tags', base)
|
||||
|
||||
if (kw) url.searchParams.set('keyword', kw)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 导出一个便捷的 toast 对象
|
||||
export const toast = {
|
||||
success: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -12,7 +12,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
error: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -23,7 +23,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
warning: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -34,7 +34,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
info: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -48,7 +48,7 @@ export const toast = {
|
||||
|
||||
// 导出 useToast composable
|
||||
export const useToast = () => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
const { useToast: useVueToast } = await import('vue-toastification')
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="site-stats-page">
|
||||
<div v-if="isLoading" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<VChart
|
||||
v-if="dauOption"
|
||||
@@ -51,8 +54,10 @@ const dauOption = ref(null)
|
||||
const newUserOption = ref(null)
|
||||
const postOption = ref(null)
|
||||
const commentOption = ref(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
const token = getToken()
|
||||
const headers = { Authorization: `Bearer ${token}` }
|
||||
|
||||
@@ -93,6 +98,7 @@ async function loadData() {
|
||||
const data = await commentRes.json()
|
||||
commentOption.value = toOption('每日回贴量', data)
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
@@ -105,4 +111,11 @@ onMounted(loadData)
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
<div class="article-container">
|
||||
<template
|
||||
v-if="
|
||||
selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'
|
||||
selectedTopic === '最新' ||
|
||||
selectedTopic === '排行榜' ||
|
||||
selectedTopic === '最新回复' ||
|
||||
selectedTopic === '精选'
|
||||
"
|
||||
>
|
||||
<div class="article-header-container">
|
||||
@@ -152,17 +155,22 @@ const route = useRoute()
|
||||
const tagOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const topics = ref(['最新回复', '最新', '精选', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
const selectedTopic = ref(
|
||||
selectedTopicCookie.value
|
||||
? selectedTopicCookie.value
|
||||
: route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复',
|
||||
)
|
||||
|
||||
let defaultTopic = '最新回复'
|
||||
|
||||
if (selectedTopicCookie.value) {
|
||||
defaultTopic = selectedTopicCookie.value
|
||||
} else if (route.query.view === 'ranking') {
|
||||
defaultTopic = '排行榜'
|
||||
} else if (route.query.view === 'latest') {
|
||||
defaultTopic = '最新'
|
||||
} else if (route.query.view === 'featured') {
|
||||
defaultTopic = '精选'
|
||||
}
|
||||
const selectedTopic = ref(defaultTopic)
|
||||
|
||||
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
@@ -236,6 +244,7 @@ const baseQuery = computed(() => ({
|
||||
const listApiPath = computed(() => {
|
||||
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
|
||||
if (selectedTopic.value === '精选') return '/api/posts/featured'
|
||||
return '/api/posts'
|
||||
})
|
||||
const buildUrl = ({ pageNo }) => {
|
||||
@@ -338,7 +347,7 @@ watch([selectedCategory, selectedTags], () => {
|
||||
watch(selectedTopic, (val) => {
|
||||
loadOptions()
|
||||
selectedTopicCookie.value = val
|
||||
if (process.client) localStorage.setItem('homeTab', val)
|
||||
if (import.meta.client) localStorage.setItem('homeTab', val)
|
||||
})
|
||||
|
||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||
|
||||
@@ -33,15 +33,13 @@
|
||||
<div v-if="selectedTab === 'control'">
|
||||
<div class="message-control-container">
|
||||
<div class="message-control-title">通知设置</div>
|
||||
<div class="message-control-push-item-container">
|
||||
<div
|
||||
v-for="pref in notificationPrefs"
|
||||
:key="pref.type"
|
||||
class="message-control-push-item"
|
||||
:class="{ select: pref.enabled }"
|
||||
@click="togglePref(pref)"
|
||||
>
|
||||
{{ formatType(pref.type) }}
|
||||
<div class="message-control-item-container">
|
||||
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
|
||||
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
|
||||
<BaseSwitch
|
||||
:model-value="pref.enabled"
|
||||
@update:modelValue="(val) => togglePref(pref, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,6 +493,37 @@
|
||||
已被管理员拒绝
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_FEATURED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您的文章
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
被收录为精选
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_DELETED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
管理员
|
||||
<template v-if="item.fromUser">
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
删除了您的帖子
|
||||
<span class="notif-content-text">
|
||||
{{ stripMarkdownLength(item.content, 100) }}
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
{{ formatType(item.type) }}
|
||||
@@ -524,7 +553,7 @@ import {
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
markNotificationRead,
|
||||
notifications,
|
||||
markAllRead,
|
||||
hasMore,
|
||||
@@ -532,6 +561,7 @@ import {
|
||||
updateNotificationPreference,
|
||||
} from '~/utils/notification'
|
||||
import TimeManager from '~/utils/time'
|
||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -564,10 +594,10 @@ const fetchPrefs = async () => {
|
||||
notificationPrefs.value = await fetchNotificationPreferences()
|
||||
}
|
||||
|
||||
const togglePref = async (pref) => {
|
||||
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
|
||||
const togglePref = async (pref, value) => {
|
||||
const ok = await updateNotificationPreference(pref.type, value)
|
||||
if (ok) {
|
||||
pref.enabled = !pref.enabled
|
||||
pref.enabled = value
|
||||
await fetchNotifications({
|
||||
page: page.value,
|
||||
size: pageSize,
|
||||
@@ -579,6 +609,14 @@ const togglePref = async (pref) => {
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
markNotificationRead(id)
|
||||
if (selectedTab.value === 'unread') {
|
||||
const index = notifications.value.findIndex((n) => n.id === id)
|
||||
if (index !== -1) notifications.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const approve = async (id, nid) => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
@@ -647,6 +685,10 @@ const formatType = (t) => {
|
||||
return '抽奖中奖了'
|
||||
case 'LOTTERY_DRAW':
|
||||
return '抽奖已开奖'
|
||||
case 'POST_DELETED':
|
||||
return '帖子被删除'
|
||||
case 'POST_FEATURED':
|
||||
return '文章被精选'
|
||||
default:
|
||||
return t
|
||||
}
|
||||
@@ -818,26 +860,21 @@ onActivated(async () => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.message-control-push-item-container {
|
||||
.message-control-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-control-push-item {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
.message-control-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.message-control-push-item.select {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
.message-control-item-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,62 +1,181 @@
|
||||
<template>
|
||||
<div class="point-mall-page">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
<div class="point-tabs">
|
||||
<div
|
||||
:class="['point-tab-item', { selected: selectedTab === 'mall' }]"
|
||||
@click="selectedTab = 'mall'"
|
||||
>
|
||||
积分兑换
|
||||
</div>
|
||||
<div
|
||||
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
|
||||
@click="selectedTab = 'history'"
|
||||
>
|
||||
积分历史
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span class="point-value">{{
|
||||
point
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="selectedTab === 'mall'">
|
||||
<div class="point-mall-page-content">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
||||
class="point-value"
|
||||
>{{ point }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="loading-points-container" v-if="historyLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<BasePlaceholder v-else-if="histories.length === 0" text="暂无积分记录" icon="fas fa-inbox" />
|
||||
<div class="timeline-container" v-else>
|
||||
<BaseTimeline :items="histories">
|
||||
<template #item="{ item }">
|
||||
<div class="history-content">
|
||||
<template v-if="item.type === 'POST'">
|
||||
发送帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT'">
|
||||
在文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
中
|
||||
<template v-if="!item.fromUserId">
|
||||
发送评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else>
|
||||
被评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
||||
帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
被
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
按赞,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT_LIKED' && item.fromUserId">
|
||||
评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
被
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
按赞,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
|
||||
邀请了好友
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
加入社区 🎉,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'FEATURE'">
|
||||
文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
被收录为精选,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REDEEM'">
|
||||
兑换商品,消耗 {{ -item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
||||
</div>
|
||||
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const selectedTab = ref('mall')
|
||||
const point = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const histories = ref([])
|
||||
const historyLoading = ref(false)
|
||||
const historyLoaded = ref(false)
|
||||
|
||||
const pointRules = [
|
||||
'发帖:每天前两次,每次 30 积分',
|
||||
@@ -64,6 +183,7 @@ const pointRules = [
|
||||
'帖子被点赞:每次 10 积分',
|
||||
'评论被点赞:每次 10 积分',
|
||||
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
|
||||
'文章被收录至精选:每次 500 积分',
|
||||
]
|
||||
|
||||
const goods = ref([])
|
||||
@@ -72,6 +192,17 @@ const contact = ref('')
|
||||
const loading = ref(false)
|
||||
const selectedGood = ref(null)
|
||||
|
||||
const iconMap = {
|
||||
POST: 'fas fa-file-alt',
|
||||
COMMENT: 'fas fa-comment',
|
||||
POST_LIKED: 'fas fa-thumbs-up',
|
||||
COMMENT_LIKED: 'fas fa-thumbs-up',
|
||||
INVITE: 'fas fa-user-plus',
|
||||
SYSTEM_ONLINE: 'fas fa-clock',
|
||||
REDEEM: 'fas fa-gift',
|
||||
FEATURE: 'fas fa-star',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
if (authState.loggedIn) {
|
||||
@@ -82,6 +213,12 @@ onMounted(async () => {
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
watch(selectedTab, (val) => {
|
||||
if (val === 'history' && !historyLoaded.value) {
|
||||
loadHistory()
|
||||
}
|
||||
})
|
||||
|
||||
const loadGoods = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
|
||||
if (res.ok) {
|
||||
@@ -89,6 +226,26 @@ const loadGoods = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadHistory = async () => {
|
||||
if (!authState.loggedIn) {
|
||||
historyLoaded.value = true
|
||||
return
|
||||
}
|
||||
historyLoading.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-histories`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
histories.value = (await res.json()).map((item) => ({
|
||||
...item,
|
||||
icon: iconMap[item.type],
|
||||
}))
|
||||
}
|
||||
historyLoading.value = false
|
||||
historyLoaded.value = true
|
||||
}
|
||||
|
||||
const openRedeem = (good) => {
|
||||
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
|
||||
toast.error('积分不足')
|
||||
@@ -129,12 +286,44 @@ const submitRedeem = async () => {
|
||||
|
||||
<style scoped>
|
||||
.point-mall-page {
|
||||
padding: 0 20px;
|
||||
max-width: var(--page-max-width);
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.point-mall-page-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.point-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.point-tab-item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.point-tab-item.selected {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.timeline-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timeline-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-points-container {
|
||||
margin-top: 100px;
|
||||
display: flex;
|
||||
@@ -215,6 +404,17 @@ const submitRedeem = async () => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.history-content {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
||||
<div
|
||||
v-if="loggedIn && !isAuthor && !subscribed"
|
||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||
class="article-subscribe-button"
|
||||
@click="subscribePost"
|
||||
>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="loggedIn && !isAuthor && subscribed"
|
||||
v-if="!closed && loggedIn && !isAuthor && subscribed"
|
||||
class="article-unsubscribe-button"
|
||||
@click="unsubscribePost"
|
||||
>
|
||||
@@ -295,7 +295,7 @@ const commentSort = ref('NEWEST')
|
||||
const isFetchingComments = ref(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const headerHeight = process.client
|
||||
const headerHeight = import.meta.client
|
||||
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
|
||||
: 0
|
||||
|
||||
@@ -309,7 +309,7 @@ useHead(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', updateCurrentIndex)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
@@ -355,7 +355,7 @@ const updateCountdown = () => {
|
||||
countdown.value = `${h}:${m}:${s}`
|
||||
}
|
||||
const startCountdown = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
updateCountdown()
|
||||
countdownTimer = setInterval(updateCountdown, 1000)
|
||||
@@ -515,7 +515,7 @@ watchEffect(() => {
|
||||
})
|
||||
|
||||
// 404 客户端跳转
|
||||
// if (postError.value?.statusCode === 404 && process.client) {
|
||||
// if (postError.value?.statusCode === 404 && import.meta.client) {
|
||||
// router.replace('/404')
|
||||
// }
|
||||
|
||||
@@ -876,12 +876,8 @@ const gotoProfile = () => {
|
||||
navigateTo(`/users/${author.value.id}`, { replace: true })
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
await refreshPost()
|
||||
await fetchComments()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const initPage = async () => {
|
||||
scrollTo(0, 0)
|
||||
await fetchComments()
|
||||
const hash = location.hash
|
||||
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
|
||||
@@ -889,6 +885,14 @@ onMounted(async () => {
|
||||
updateCurrentIndex()
|
||||
window.addEventListener('scroll', updateCurrentIndex)
|
||||
jumpToHashComment()
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
await initPage()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await initPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1067,6 +1071,7 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-closed-button,
|
||||
.article-subscribe-button-text,
|
||||
.article-unsubscribe-button-text {
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -38,10 +38,7 @@
|
||||
</div>
|
||||
<div class="form-row switch-row">
|
||||
<div class="setting-title">毛玻璃效果</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="frosted" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<BaseSwitch v-model="frosted" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||
@@ -76,6 +73,7 @@ import { ref, onMounted, watch } from 'vue'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||
@@ -318,51 +316,6 @@ const save = async () => {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clearToken } from '~/utils/auth'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = async (input, init) => {
|
||||
const response = await originalFetch(input, init)
|
||||
|
||||
@@ -4,7 +4,7 @@ import '~/assets/toast.css'
|
||||
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
// 确保只在客户端环境中注册插件
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
// 使用动态导入来避免 CommonJS 模块问题
|
||||
const { default: Toast, POSITION } = await import('vue-toastification')
|
||||
|
||||
1
frontend_nuxt/public/tencent2707107139169774686.txt
Normal file
1
frontend_nuxt/public/tencent2707107139169774686.txt
Normal file
@@ -0,0 +1 @@
|
||||
1839503219847005265
|
||||
@@ -12,7 +12,7 @@ export const authState = reactive({
|
||||
role: null,
|
||||
})
|
||||
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
authState.loggedIn =
|
||||
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||
authState.userId = localStorage.getItem(USER_ID_KEY)
|
||||
@@ -21,18 +21,18 @@ if (process.client) {
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return process.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
authState.loggedIn = true
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
clearUserInfo()
|
||||
authState.loggedIn = false
|
||||
@@ -40,7 +40,7 @@ export function clearToken() {
|
||||
}
|
||||
|
||||
export function setUserInfo({ id, username }) {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
authState.userId = id
|
||||
authState.username = username
|
||||
if (arguments[0] && arguments[0].role) {
|
||||
@@ -53,7 +53,7 @@ export function setUserInfo({ id, username }) {
|
||||
}
|
||||
|
||||
export function clearUserInfo() {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem(USER_ID_KEY)
|
||||
localStorage.removeItem(USERNAME_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const medalTitles = {
|
||||
COMMENT: '评论达人',
|
||||
POST: '发帖达人',
|
||||
FEATURED: '精选作者',
|
||||
SEED: '种子用户',
|
||||
CONTRIBUTOR: '贡献者',
|
||||
PIONEER: '开山鼻祖',
|
||||
|
||||
@@ -26,6 +26,8 @@ const iconMap = {
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
POST_DELETED: 'fas fa-trash',
|
||||
POST_FEATURED: 'fas fa-star',
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
@@ -158,7 +160,7 @@ function createFetchNotifications() {
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
@@ -168,7 +170,7 @@ function createFetchNotifications() {
|
||||
emoji: reactionEmojiMap[n.reactionType],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -180,7 +182,19 @@ function createFetchNotifications() {
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_DELETED') {
|
||||
arr.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -191,7 +205,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
@@ -201,7 +215,7 @@ function createFetchNotifications() {
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
@@ -211,7 +225,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -222,7 +236,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -237,7 +251,7 @@ function createFetchNotifications() {
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -249,7 +263,18 @@ function createFetchNotifications() {
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_FEATURED') {
|
||||
arr.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
@@ -277,7 +302,7 @@ function createFetchNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
const markNotificationRead = async (id) => {
|
||||
if (!id) return
|
||||
const n = notifications.value.find((n) => n.id === id)
|
||||
if (!n || n.read) return
|
||||
@@ -319,7 +344,7 @@ function createFetchNotifications() {
|
||||
}
|
||||
return {
|
||||
fetchNotifications,
|
||||
markRead,
|
||||
markNotificationRead,
|
||||
notifications,
|
||||
isLoadingMessage,
|
||||
markAllRead,
|
||||
@@ -329,7 +354,7 @@ function createFetchNotifications() {
|
||||
|
||||
export const {
|
||||
fetchNotifications,
|
||||
markRead,
|
||||
markNotificationRead,
|
||||
notifications,
|
||||
isLoadingMessage,
|
||||
markAllRead,
|
||||
|
||||
Reference in New Issue
Block a user