mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-05 11:30:46 +08:00
fix: 迁移部分页面为setup
This commit is contained in:
@@ -37,8 +37,10 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const props = defineProps({
|
||||
medals: {
|
||||
|
||||
@@ -26,49 +26,43 @@
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'CategorySelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
const props = defineProps({
|
||||
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(
|
||||
() => props.options,
|
||||
(val) => {
|
||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||
},
|
||||
)
|
||||
|
||||
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 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),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -88,11 +88,11 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||
import { getMedalTitle } from '~/utils/medal'
|
||||
@@ -100,214 +100,182 @@ import TimeManager from '~/utils/time'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import CommentEditor from '~/components/CommentEditor.vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const CommentItem = {
|
||||
name: 'CommentItem',
|
||||
emits: ['deleted'],
|
||||
props: {
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
defaultShowReplies: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
||||
watch(
|
||||
() => props.defaultShowReplies,
|
||||
(val) => {
|
||||
showReplies.value = props.level === 0 ? true : val
|
||||
},
|
||||
)
|
||||
const showEditor = ref(false)
|
||||
const editorWrapper = ref(null)
|
||||
const isWaitingForReply = ref(false)
|
||||
const lightboxVisible = ref(false)
|
||||
const lightboxIndex = ref(0)
|
||||
const lightboxImgs = ref([])
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||
const toggleReplies = () => {
|
||||
showReplies.value = !showReplies.value
|
||||
}
|
||||
const toggleEditor = () => {
|
||||
showEditor.value = !showEditor.value
|
||||
if (showEditor.value) {
|
||||
setTimeout(() => {
|
||||
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
defaultShowReplies: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 合并所有子回复为一个扁平数组
|
||||
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 emit = defineEmits(['deleted'])
|
||||
|
||||
const router = useRouter()
|
||||
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
||||
watch(
|
||||
() => props.defaultShowReplies,
|
||||
(val) => {
|
||||
showReplies.value = props.level === 0 ? true : val
|
||||
},
|
||||
)
|
||||
const showEditor = ref(false)
|
||||
const editorWrapper = ref(null)
|
||||
const isWaitingForReply = ref(false)
|
||||
const lightboxVisible = ref(false)
|
||||
const lightboxIndex = ref(0)
|
||||
const lightboxImgs = ref([])
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||
|
||||
const toggleReplies = () => {
|
||||
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(() => {
|
||||
if (props.level < 1) {
|
||||
return props.comment.reply
|
||||
}
|
||||
const replyList = computed(() => {
|
||||
if (props.level < 1) {
|
||||
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 }),
|
||||
})
|
||||
|
||||
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('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}`),
|
||||
})
|
||||
console.debug('Delete comment response status', res.status)
|
||||
if (res.ok) {
|
||||
toast.success('已删除')
|
||||
emit('deleted', props.comment.id)
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
clear()
|
||||
showEditor.value = false
|
||||
toast.success('回复成功')
|
||||
} else if (res.status === 429) {
|
||||
toast.error('回复过于频繁,请稍后再试')
|
||||
} else {
|
||||
toast.error(`回复失败: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
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)
|
||||
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,
|
||||
}
|
||||
},
|
||||
} catch (e) {
|
||||
console.debug('Submit reply error', e)
|
||||
toast.error(`回复失败: ${e.message}`)
|
||||
} finally {
|
||||
isWaitingForReply.value = false
|
||||
}
|
||||
}
|
||||
|
||||
CommentItem.components = {
|
||||
CommentItem,
|
||||
CommentEditor,
|
||||
BaseTimeline,
|
||||
ReactionsGroup,
|
||||
DropdownMenu,
|
||||
VueEasyLightbox,
|
||||
LoginOverlay,
|
||||
const copyCommentLink = () => {
|
||||
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
toast.success('已复制')
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,95 +11,87 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import ActivityPopup from '~/components/ActivityPopup.vue'
|
||||
import MedalPopup from '~/components/MedalPopup.vue'
|
||||
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { authState } from '~/utils/auth'
|
||||
|
||||
export default {
|
||||
name: 'GlobalPopups',
|
||||
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
|
||||
data() {
|
||||
return {
|
||||
showMilkTeaPopup: false,
|
||||
milkTeaIcon: '',
|
||||
showNotificationPopup: false,
|
||||
showMedalPopup: false,
|
||||
newMedals: [],
|
||||
const showMilkTeaPopup = ref(false)
|
||||
const milkTeaIcon = ref('')
|
||||
const showNotificationPopup = ref(false)
|
||||
const showMedalPopup = ref(false)
|
||||
const newMedals = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
await checkMilkTeaActivity()
|
||||
if (showMilkTeaPopup.value) return
|
||||
|
||||
await checkNotificationSetting()
|
||||
if (showNotificationPopup.value) return
|
||||
|
||||
await checkNewMedals()
|
||||
})
|
||||
|
||||
const checkMilkTeaActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.checkMilkTeaActivity()
|
||||
if (this.showMilkTeaPopup) return
|
||||
|
||||
await this.checkNotificationSetting()
|
||||
if (this.showNotificationPopup) return
|
||||
|
||||
await this.checkNewMedals()
|
||||
},
|
||||
methods: {
|
||||
async checkMilkTeaActivity() {
|
||||
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) {
|
||||
this.milkTeaIcon = a.icon
|
||||
this.showMilkTeaPopup = true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore network errors
|
||||
} catch (e) {
|
||||
// ignore network errors
|
||||
}
|
||||
}
|
||||
const closeMilkTeaPopup = () => {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
showMilkTeaPopup.value = false
|
||||
checkNotificationSetting()
|
||||
}
|
||||
const checkNotificationSetting = async () => {
|
||||
if (!process.client) return
|
||||
if (!authState.loggedIn) return
|
||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||
showNotificationPopup.value = true
|
||||
}
|
||||
const closeNotificationPopup = () => {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('notificationSettingPopupShown', 'true')
|
||||
showNotificationPopup.value = false
|
||||
checkNewMedals()
|
||||
}
|
||||
const checkNewMedals = async () => {
|
||||
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) {
|
||||
newMedals.value = m
|
||||
showMedalPopup.value = true
|
||||
}
|
||||
},
|
||||
closeMilkTeaPopup() {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
this.showMilkTeaPopup = false
|
||||
this.checkNotificationSetting()
|
||||
},
|
||||
async checkNotificationSetting() {
|
||||
if (!process.client) return
|
||||
if (!authState.loggedIn) return
|
||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||
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
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
const closeMedalPopup = () => {
|
||||
if (!process.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]))
|
||||
showMedalPopup.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -123,125 +123,103 @@
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { authState } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'MenuComponent',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
async setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const isLoadingCategory = ref(false)
|
||||
const isLoadingTag = ref(false)
|
||||
const categoryData = ref([])
|
||||
const tagData = ref([])
|
||||
})
|
||||
|
||||
const fetchCategoryData = async () => {
|
||||
isLoadingCategory.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
const data = await res.json()
|
||||
categoryData.value = data
|
||||
isLoadingCategory.value = false
|
||||
}
|
||||
const emit = defineEmits(['item-click'])
|
||||
|
||||
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 router = useRouter()
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const isLoadingCategory = ref(false)
|
||||
const isLoadingTag = ref(false)
|
||||
const categoryData = ref([])
|
||||
const tagData = ref([])
|
||||
|
||||
const 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()])
|
||||
|
||||
return {
|
||||
categoryData,
|
||||
tagData,
|
||||
categoryOpen,
|
||||
tagOpen,
|
||||
isLoadingCategory,
|
||||
isLoadingTag,
|
||||
iconClass,
|
||||
unreadCount,
|
||||
showUnreadCount,
|
||||
shouldShowStats,
|
||||
cycleTheme,
|
||||
handleHomeClick,
|
||||
handleItemClick,
|
||||
isImageIcon,
|
||||
gotoCategory,
|
||||
gotoTag,
|
||||
}
|
||||
},
|
||||
const fetchCategoryData = async () => {
|
||||
isLoadingCategory.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
const data = await res.json()
|
||||
categoryData.value = data
|
||||
isLoadingCategory.value = false
|
||||
}
|
||||
|
||||
const fetchTagData = async () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -57,73 +57,66 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import ProgressBar from '~/components/ProgressBar.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'MilkTeaActivityComponent',
|
||||
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
||||
data() {
|
||||
return {
|
||||
info: { redeemCount: 0, ended: false },
|
||||
user: null,
|
||||
dialogVisible: false,
|
||||
contact: '',
|
||||
loading: false,
|
||||
isLoadingUser: true,
|
||||
const info = ref({ redeemCount: 0, ended: false })
|
||||
const user = ref(null)
|
||||
const dialogVisible = ref(false)
|
||||
const contact = ref('')
|
||||
const loading = ref(false)
|
||||
const isLoadingUser = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadInfo()
|
||||
isLoadingUser.value = true
|
||||
user.value = await fetchCurrentUser()
|
||||
isLoadingUser.value = false
|
||||
})
|
||||
const loadInfo = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||
if (res.ok) {
|
||||
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('兑换成功!')
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadInfo()
|
||||
this.isLoadingUser = true
|
||||
this.user = await fetchCurrentUser()
|
||||
this.isLoadingUser = false
|
||||
},
|
||||
methods: {
|
||||
async loadInfo() {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||
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
|
||||
},
|
||||
},
|
||||
dialogVisible.value = false
|
||||
await loadInfo()
|
||||
} else {
|
||||
toast.error('兑换失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -46,11 +46,16 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const reactions = ref(props.modelValue)
|
||||
const reactionTypes = ref([])
|
||||
|
||||
let cachedTypes = null
|
||||
const fetchTypes = async () => {
|
||||
@@ -71,151 +76,129 @@ const fetchTypes = async () => {
|
||||
return cachedTypes
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ReactionsGroup',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
contentType: { type: String, required: true },
|
||||
contentId: { type: [Number, String], required: true },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const reactions = ref(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => (reactions.value = v),
|
||||
)
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
contentType: { type: String, required: true },
|
||||
contentId: { type: [Number, String], required: true },
|
||||
})
|
||||
|
||||
const reactionTypes = ref([])
|
||||
onMounted(async () => {
|
||||
reactionTypes.value = await fetchTypes()
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(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 }),
|
||||
})
|
||||
|
||||
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]
|
||||
if (res.ok) {
|
||||
if (res.status === 204) {
|
||||
// removal already reflected
|
||||
} else {
|
||||
tempReaction = { type, user: authState.username }
|
||||
reactions.value.push(tempReaction)
|
||||
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)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ type }),
|
||||
})
|
||||
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('操作失败')
|
||||
} 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('操作失败')
|
||||
}
|
||||
|
||||
return {
|
||||
reactionEmojiMap,
|
||||
counts,
|
||||
totalCount,
|
||||
likeCount,
|
||||
displayedReactions,
|
||||
panelTypes,
|
||||
panelVisible,
|
||||
openPanel,
|
||||
scheduleHide,
|
||||
cancelHide,
|
||||
toggleReaction,
|
||||
userReacted,
|
||||
} 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('操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -36,98 +36,82 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'SearchDropdown',
|
||||
components: { Dropdown },
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const toggle = () => {
|
||||
dropdown.value.toggle()
|
||||
}
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
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 = ''
|
||||
})
|
||||
|
||||
return {
|
||||
keyword,
|
||||
selected,
|
||||
fetchResults,
|
||||
highlight,
|
||||
iconMap,
|
||||
isMobile,
|
||||
dropdown,
|
||||
onClose,
|
||||
toggle,
|
||||
}
|
||||
},
|
||||
const toggle = () => {
|
||||
dropdown.value.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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -28,114 +28,105 @@
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'TagSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
creatable: { type: Boolean, default: false },
|
||||
options: { type: Array, default: () => [] },
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const props = defineProps({
|
||||
modelValue: { 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(
|
||||
() => props.options,
|
||||
(val) => {
|
||||
providedTags.value = Array.isArray(val) ? [...val] : []
|
||||
},
|
||||
)
|
||||
const mergedOptions = computed(() => {
|
||||
const arr = [...providedTags.value, ...localTags.value]
|
||||
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||
})
|
||||
|
||||
const mergedOptions = computed(() => {
|
||||
const arr = [...providedTags.value, ...localTags.value]
|
||||
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||
})
|
||||
|
||||
const isImageIcon = (icon) => {
|
||||
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 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)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user