mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-12 08:46:09 +00:00
fix: 全局格式化
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
<div
|
||||
v-for="medal in sortedMedals"
|
||||
:key="medal.type"
|
||||
:class="['achievements-list-item', { select: medal.selected && canSelect, clickable: canSelect }]"
|
||||
:class="[
|
||||
'achievements-list-item',
|
||||
{ select: medal.selected && canSelect, clickable: canSelect },
|
||||
]"
|
||||
@click="selectMedal(medal)"
|
||||
>
|
||||
<img
|
||||
@@ -11,7 +14,9 @@
|
||||
:alt="medal.title"
|
||||
:class="['achievements-list-item-icon', { not_completed: !medal.completed }]"
|
||||
/>
|
||||
<div v-if="medal.selected && canSelect" class="achievements-list-item-top-right-label">展示</div>
|
||||
<div v-if="medal.selected && canSelect" class="achievements-list-item-top-right-label">
|
||||
展示
|
||||
</div>
|
||||
<div class="achievements-list-item-title">{{ medal.title }}</div>
|
||||
<div class="achievements-list-item-description">
|
||||
{{ medal.description }}
|
||||
@@ -38,12 +43,12 @@ const props = defineProps({
|
||||
medals: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
default: () => [],
|
||||
},
|
||||
canSelect: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const sortedMedals = computed(() => {
|
||||
@@ -64,12 +69,14 @@ const selectMedal = async (medal) => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getToken()}`
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
body: JSON.stringify({ type: medal.type })
|
||||
body: JSON.stringify({ type: medal.type }),
|
||||
})
|
||||
if (res.ok) {
|
||||
props.medals.forEach(m => { m.selected = m.type === medal.type })
|
||||
props.medals.forEach((m) => {
|
||||
m.selected = m.type === medal.type
|
||||
})
|
||||
toast('展示勋章已更新')
|
||||
} else {
|
||||
toast('选择勋章失败')
|
||||
@@ -78,7 +85,6 @@ const selectMedal = async (medal) => {
|
||||
toast('选择勋章失败')
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -158,6 +164,4 @@ const selectMedal = async (medal) => {
|
||||
min-width: calc(50% - 30px);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ export default {
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
icon: String,
|
||||
text: String
|
||||
text: String,
|
||||
},
|
||||
emits: ['close'],
|
||||
setup (props, { emit }) {
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const gotoActivity = () => {
|
||||
emit('close')
|
||||
@@ -32,7 +32,7 @@ export default {
|
||||
}
|
||||
const close = () => emit('close')
|
||||
return { gotoActivity, close }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useRouter } from 'vue-router'
|
||||
export default {
|
||||
name: 'ArticleCategory',
|
||||
props: {
|
||||
category: { type: Object, default: null }
|
||||
category: { type: Object, default: null },
|
||||
},
|
||||
setup(props) {
|
||||
const router = useRouter()
|
||||
@@ -30,7 +30,7 @@ export default {
|
||||
})
|
||||
}
|
||||
return { gotoCategory }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,30 +23,28 @@ import { useRouter } from 'vue-router'
|
||||
export default {
|
||||
name: 'ArticleTags',
|
||||
props: {
|
||||
tags: { type: Array, default: () => [] }
|
||||
tags: { type: Array, default: () => [] },
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const gotoTag = tag => {
|
||||
const gotoTag = (tag) => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
return { gotoTag }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
<style scoped>
|
||||
.article-tags-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.article-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -74,6 +72,4 @@ export default {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ export default {
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['close', 'crop'],
|
||||
data() {
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
} else {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.show) {
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true
|
||||
responsive: true,
|
||||
})
|
||||
},
|
||||
destroy() {
|
||||
@@ -64,15 +64,15 @@ export default {
|
||||
},
|
||||
onConfirm() {
|
||||
if (!this.cropper) return
|
||||
this.cropper.getCroppedCanvas({ width: 256, height: 256 }).toBlob(blob => {
|
||||
this.cropper.getCroppedCanvas({ width: 256, height: 256 }).toBlob((blob) => {
|
||||
const file = new File([blob], 'avatar.png', { type: 'image/png' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.$emit('crop', { file, url })
|
||||
this.$emit('close')
|
||||
this.destroy()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
opacity: 1.0;
|
||||
opacity: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -139,4 +139,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BaseInput',
|
||||
@@ -32,7 +31,7 @@ export default {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
textarea: { type: Boolean, default: false }
|
||||
textarea: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
@@ -42,9 +41,9 @@ export default {
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:modelValue', val)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,7 +74,7 @@ export default {
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
resize: none;
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export default {
|
||||
name: 'BasePlaceholder',
|
||||
props: {
|
||||
text: { type: String, default: '' },
|
||||
icon: { type: String, default: 'fas fa-inbox' }
|
||||
}
|
||||
icon: { type: String, default: 'fas fa-inbox' },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ export default {
|
||||
name: 'BasePopup',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
closeOnOverlay: { type: Boolean, default: true }
|
||||
closeOnOverlay: { type: Boolean, default: true },
|
||||
},
|
||||
emits: ['close'],
|
||||
methods: {
|
||||
onOverlayClick () {
|
||||
onOverlayClick() {
|
||||
if (this.closeOnOverlay) this.$emit('close')
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
export default {
|
||||
name: 'BaseTimeline',
|
||||
props: {
|
||||
items: { type: Array, default: () => [] }
|
||||
}
|
||||
items: { type: Array, default: () => [] },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -92,12 +92,10 @@ export default {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.timeline-icon {
|
||||
margin-right: 2px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CallbackPage'
|
||||
name: 'CallbackPage',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<template>
|
||||
<Dropdown v-model="selected" :fetch-options="fetchCategories" placeholder="选择分类" :initial-options="providedOptions">
|
||||
<Dropdown
|
||||
v-model="selected"
|
||||
:fetch-options="fetchCategories"
|
||||
placeholder="选择分类"
|
||||
:initial-options="providedOptions"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-main">
|
||||
<template v-if="option.icon">
|
||||
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
||||
<img
|
||||
v-if="isImageIcon(option.icon)"
|
||||
:src="option.icon"
|
||||
class="option-icon"
|
||||
:alt="option.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
@@ -26,7 +36,7 @@ export default {
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] }
|
||||
options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
@@ -34,9 +44,9 @@ export default {
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
val => {
|
||||
(val) => {
|
||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const fetchCategories = async () => {
|
||||
@@ -46,18 +56,18 @@ export default {
|
||||
return [{ id: '', name: '无分类' }, ...data]
|
||||
}
|
||||
|
||||
const isImageIcon = icon => {
|
||||
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)
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
return { fetchCategories, selected, isImageIcon, providedOptions }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<template>
|
||||
<div class="comment-editor-container">
|
||||
<div class="comment-editor-wrapper">
|
||||
<div class="comment-editor-wrapper">
|
||||
<div :id="editorId" ref="vditorElement"></div>
|
||||
<LoginOverlay v-if="showLoginOverlay" />
|
||||
</div>
|
||||
<div class="comment-bottom-container">
|
||||
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading">
|
||||
发布评论
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> 发布中...
|
||||
</template>
|
||||
<template v-if="!loading"> 发布评论 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发布中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +19,7 @@ import { themeState } from '../utils/theme'
|
||||
import {
|
||||
createVditor,
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil
|
||||
getPreviewTheme as getPreviewThemeUtil,
|
||||
} from '../utils/vditor'
|
||||
import LoginOverlay from './LoginOverlay.vue'
|
||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||
@@ -34,24 +30,24 @@ export default {
|
||||
props: {
|
||||
editorId: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
showLoginOverlay: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
parentUserName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
components: { LoginOverlay },
|
||||
setup(props, { emit }) {
|
||||
@@ -87,7 +83,7 @@ export default {
|
||||
placeholder: '说点什么...',
|
||||
preview: {
|
||||
actions: [],
|
||||
markdown: { toc: false }
|
||||
markdown: { toc: false },
|
||||
},
|
||||
input(value) {
|
||||
text.value = value
|
||||
@@ -97,7 +93,7 @@ export default {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
},
|
||||
})
|
||||
// applyTheme()
|
||||
})
|
||||
@@ -108,37 +104,37 @@ export default {
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
val => {
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.disabled) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
val => {
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.loading) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => themeState.mode,
|
||||
() => {
|
||||
applyTheme()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return { submit, isDisabled, editorId }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div class="info-content-container" :id="'comment-' + comment.id" :style="{
|
||||
...(level > 0 ? { /*borderLeft: '1px solid #e0e0e0', */borderBottom: 'none' } : {})
|
||||
}">
|
||||
<div
|
||||
class="info-content-container"
|
||||
:id="'comment-' + comment.id"
|
||||
:style="{
|
||||
...(level > 0 ? { /*borderLeft: '1px solid #e0e0e0', */ borderBottom: 'none' } : {}),
|
||||
}"
|
||||
>
|
||||
<!-- <div class="user-avatar-container">
|
||||
<div class="user-avatar-item">
|
||||
<img class="user-avatar-item-img" :src="comment.avatar" alt="avatar" />
|
||||
@@ -16,7 +20,8 @@
|
||||
v-if="comment.medal"
|
||||
class="medal-name"
|
||||
:to="`/users/${comment.userId}?tab=achievements`"
|
||||
>{{ getMedalTitle(comment.medal) }}</router-link>
|
||||
>{{ getMedalTitle(comment.medal) }}</router-link
|
||||
>
|
||||
<span v-if="level >= 2">
|
||||
<i class="fas fa-reply reply-icon"></i>
|
||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||
@@ -31,7 +36,11 @@
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-content-text" v-html="renderMarkdown(comment.text)" @click="handleContentClick"></div>
|
||||
<div
|
||||
class="info-content-text"
|
||||
v-html="renderMarkdown(comment.text)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
<div class="article-footer-container">
|
||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||
@@ -43,8 +52,14 @@
|
||||
</ReactionsGroup>
|
||||
</div>
|
||||
<div class="comment-editor-wrapper" ref="editorWrapper">
|
||||
<CommentEditor v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" :disabled="!loggedIn"
|
||||
:show-login-overlay="!loggedIn" :parent-user-name="comment.userName" />
|
||||
<CommentEditor
|
||||
v-if="showEditor"
|
||||
@submit="submitReply"
|
||||
:loading="isWaitingForReply"
|
||||
:disabled="!loggedIn"
|
||||
:show-login-overlay="!loggedIn"
|
||||
:parent-user-name="comment.userName"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="replyCount && level < 2" class="reply-toggle" @click="toggleReplies">
|
||||
<i v-if="showReplies" class="fas fa-chevron-up reply-toggle-icon"></i>
|
||||
@@ -54,12 +69,21 @@
|
||||
<div v-if="showReplies && level < 2" class="reply-list">
|
||||
<BaseTimeline :items="replyList">
|
||||
<template #item="{ item }">
|
||||
<CommentItem :key="item.id" :comment="item" :level="level + 1" :default-show-replies="item.openReplies" />
|
||||
<CommentItem
|
||||
:key="item.id"
|
||||
:comment="item"
|
||||
:level="level + 1"
|
||||
:default-show-replies="item.openReplies"
|
||||
/>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<vue-easy-lightbox :visible="lightboxVisible" :imgs="lightboxImgs" :index="lightboxIndex"
|
||||
@hide="lightboxVisible = false" />
|
||||
<vue-easy-lightbox
|
||||
:visible="lightboxVisible"
|
||||
:imgs="lightboxImgs"
|
||||
:index="lightboxIndex"
|
||||
@hide="lightboxVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -85,16 +109,16 @@ const CommentItem = {
|
||||
props: {
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
default: 0,
|
||||
},
|
||||
defaultShowReplies: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
@@ -103,7 +127,7 @@ const CommentItem = {
|
||||
() => props.defaultShowReplies,
|
||||
(val) => {
|
||||
showReplies.value = props.level === 0 ? true : val
|
||||
}
|
||||
},
|
||||
)
|
||||
const showEditor = ref(false)
|
||||
const editorWrapper = ref(null)
|
||||
@@ -149,7 +173,9 @@ const CommentItem = {
|
||||
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() }] : []
|
||||
isAuthor.value || isAdmin.value
|
||||
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
|
||||
: [],
|
||||
)
|
||||
const deleteComment = async () => {
|
||||
const token = getToken()
|
||||
@@ -160,7 +186,7 @@ const CommentItem = {
|
||||
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}` }
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
console.debug('Delete comment response status', res.status)
|
||||
if (res.ok) {
|
||||
@@ -184,7 +210,7 @@ const CommentItem = {
|
||||
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 })
|
||||
body: JSON.stringify({ content: text }),
|
||||
})
|
||||
console.debug('Submit reply response status', res.status)
|
||||
if (res.ok) {
|
||||
@@ -200,7 +226,7 @@ const CommentItem = {
|
||||
text: data.content,
|
||||
parentUserName: parentUserName,
|
||||
reactions: [],
|
||||
reply: (data.replies || []).map(r => ({
|
||||
reply: (data.replies || []).map((r) => ({
|
||||
id: r.id,
|
||||
userName: r.author.username,
|
||||
time: TimeManager.format(r.createdAt),
|
||||
@@ -210,11 +236,11 @@ const CommentItem = {
|
||||
reply: [],
|
||||
openReplies: false,
|
||||
src: r.author.avatar,
|
||||
iconClick: () => router.push(`/users/${r.author.id}`)
|
||||
iconClick: () => router.push(`/users/${r.author.id}`),
|
||||
})),
|
||||
openReplies: false,
|
||||
src: data.author.avatar,
|
||||
iconClick: () => router.push(`/users/${data.author.id}`)
|
||||
iconClick: () => router.push(`/users/${data.author.id}`),
|
||||
})
|
||||
clear()
|
||||
showEditor.value = false
|
||||
@@ -237,21 +263,49 @@ const CommentItem = {
|
||||
toast.success('已复制')
|
||||
})
|
||||
}
|
||||
const handleContentClick = e => {
|
||||
const handleContentClick = (e) => {
|
||||
handleMarkdownClick(e)
|
||||
if (e.target.tagName === 'IMG') {
|
||||
const container = e.target.parentNode
|
||||
const imgs = [...container.querySelectorAll('img')].map(i => i.src)
|
||||
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 }
|
||||
}
|
||||
return {
|
||||
showReplies,
|
||||
toggleReplies,
|
||||
showEditor,
|
||||
toggleEditor,
|
||||
submitReply,
|
||||
copyCommentLink,
|
||||
renderMarkdown,
|
||||
isWaitingForReply,
|
||||
commentMenuItems,
|
||||
deleteComment,
|
||||
lightboxVisible,
|
||||
lightboxIndex,
|
||||
lightboxImgs,
|
||||
handleContentClick,
|
||||
loggedIn,
|
||||
replyCount,
|
||||
replyList,
|
||||
getMedalTitle,
|
||||
editorWrapper,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline, ReactionsGroup, DropdownMenu, VueEasyLightbox, LoginOverlay }
|
||||
CommentItem.components = {
|
||||
CommentItem,
|
||||
CommentEditor,
|
||||
BaseTimeline,
|
||||
ReactionsGroup,
|
||||
DropdownMenu,
|
||||
VueEasyLightbox,
|
||||
LoginOverlay,
|
||||
}
|
||||
|
||||
export default CommentItem
|
||||
</script>
|
||||
@@ -263,7 +317,8 @@ export default CommentItem
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.reply-list {}
|
||||
.reply-list {
|
||||
}
|
||||
|
||||
.comment-reaction {
|
||||
color: var(--primary-color);
|
||||
|
||||
@@ -49,11 +49,7 @@
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
open &&
|
||||
!isMobile &&
|
||||
(loading || filteredOptions.length > 0 || showSearch)
|
||||
"
|
||||
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
|
||||
:class="['dropdown-menu', menuClass]"
|
||||
v-click-outside="close"
|
||||
>
|
||||
@@ -62,32 +58,18 @@
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<l-hatch
|
||||
size="20"
|
||||
stroke="4"
|
||||
speed="3.5"
|
||||
color="var(--primary-color)"
|
||||
></l-hatch>
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="[
|
||||
'dropdown-option',
|
||||
optionClass,
|
||||
{ selected: isSelected(o.id) },
|
||||
]"
|
||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<img
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
@@ -107,32 +89,18 @@
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<l-hatch
|
||||
size="20"
|
||||
stroke="4"
|
||||
speed="3.5"
|
||||
color="var(--primary-color)"
|
||||
></l-hatch>
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="[
|
||||
'dropdown-option',
|
||||
optionClass,
|
||||
{ selected: isSelected(o.id) },
|
||||
]"
|
||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<img
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
@@ -146,33 +114,30 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch, onMounted } from "vue"
|
||||
import { useIsMobile } from "~/utils/screen"
|
||||
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
name: "BaseDropdown",
|
||||
name: 'BaseDropdown',
|
||||
props: {
|
||||
modelValue: { type: [Array, String, Number], default: () => [] },
|
||||
placeholder: { type: String, default: "返回" },
|
||||
placeholder: { type: String, default: '返回' },
|
||||
multiple: { type: Boolean, default: false },
|
||||
fetchOptions: { type: Function, required: true },
|
||||
remote: { type: Boolean, default: false },
|
||||
menuClass: { type: String, default: "" },
|
||||
optionClass: { type: String, default: "" },
|
||||
menuClass: { type: String, default: '' },
|
||||
optionClass: { type: String, default: '' },
|
||||
showSearch: { type: Boolean, default: true },
|
||||
initialOptions: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ["update:modelValue", "update:search", "close"],
|
||||
emits: ['update:modelValue', 'update:search', 'close'],
|
||||
setup(props, { emit, expose }) {
|
||||
const open = ref(false)
|
||||
const search = ref("")
|
||||
const search = ref('')
|
||||
const setSearch = (val) => {
|
||||
search.value = val
|
||||
}
|
||||
const options = ref(
|
||||
Array.isArray(props.initialOptions) ? [...props.initialOptions] : []
|
||||
)
|
||||
const options = ref(Array.isArray(props.initialOptions) ? [...props.initialOptions] : [])
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const wrapper = ref(null)
|
||||
@@ -180,12 +145,12 @@ export default {
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
if (!open.value) emit("close")
|
||||
if (!open.value) emit('close')
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
open.value = false
|
||||
emit("close")
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const select = (id) => {
|
||||
@@ -197,23 +162,21 @@ export default {
|
||||
} else {
|
||||
arr.push(id)
|
||||
}
|
||||
emit("update:modelValue", arr)
|
||||
emit('update:modelValue', arr)
|
||||
} else {
|
||||
emit("update:modelValue", id)
|
||||
emit('update:modelValue', id)
|
||||
close()
|
||||
}
|
||||
search.value = ""
|
||||
search.value = ''
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (props.remote) return options.value
|
||||
if (!search.value) return options.value
|
||||
return options.value.filter((o) =>
|
||||
o.name.toLowerCase().includes(search.value.toLowerCase())
|
||||
)
|
||||
return options.value.filter((o) => o.name.toLowerCase().includes(search.value.toLowerCase()))
|
||||
})
|
||||
|
||||
const loadOptions = async (kw = "") => {
|
||||
const loadOptions = async (kw = '') => {
|
||||
if (!props.remote && loaded.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -233,7 +196,7 @@ export default {
|
||||
if (Array.isArray(val)) {
|
||||
options.value = [...val]
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(open, async (val) => {
|
||||
@@ -247,7 +210,7 @@ export default {
|
||||
})
|
||||
|
||||
watch(search, async (val) => {
|
||||
emit("update:search", val)
|
||||
emit('update:search', val)
|
||||
if (props.remote && open.value) {
|
||||
await loadOptions(val)
|
||||
}
|
||||
@@ -261,9 +224,7 @@ export default {
|
||||
|
||||
const selectedLabels = computed(() => {
|
||||
if (props.multiple) {
|
||||
return options.value.filter((o) =>
|
||||
(props.modelValue || []).includes(o.id)
|
||||
)
|
||||
return options.value.filter((o) => (props.modelValue || []).includes(o.id))
|
||||
}
|
||||
const match = options.value.find((o) => o.id === props.modelValue)
|
||||
return match ? [match] : []
|
||||
@@ -275,7 +236,7 @@ export default {
|
||||
|
||||
const isImageIcon = (icon) => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith("/")
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
expose({ toggle, close })
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
export default {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
items: { type: Array, default: () => [] }
|
||||
items: { type: Array, default: () => [] },
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
const visible = ref(false)
|
||||
@@ -33,13 +33,13 @@ export default {
|
||||
const close = () => {
|
||||
visible.value = false
|
||||
}
|
||||
const handle = item => {
|
||||
const handle = (item) => {
|
||||
close()
|
||||
if (item && typeof item.onClick === 'function') {
|
||||
item.onClick()
|
||||
}
|
||||
}
|
||||
const clickOutside = e => {
|
||||
const clickOutside = (e) => {
|
||||
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
||||
close()
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default {
|
||||
})
|
||||
expose({ close })
|
||||
return { visible, toggle, wrapper, handle }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
text="建站送奶茶活动火热进行中,快来参与吧!"
|
||||
@close="closeMilkTeaPopup"
|
||||
/>
|
||||
<MedalPopup
|
||||
:visible="showMedalPopup"
|
||||
:medals="newMedals"
|
||||
@close="closeMedalPopup"
|
||||
/>
|
||||
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,29 +19,29 @@ import { authState } from '~/utils/auth'
|
||||
export default {
|
||||
name: 'GlobalPopups',
|
||||
components: { ActivityPopup, MedalPopup },
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
showMilkTeaPopup: false,
|
||||
milkTeaIcon: '',
|
||||
showMedalPopup: false,
|
||||
newMedals: []
|
||||
newMedals: [],
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
async mounted() {
|
||||
await this.checkMilkTeaActivity()
|
||||
if (!this.showMilkTeaPopup) {
|
||||
await this.checkNewMedals()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async checkMilkTeaActivity () {
|
||||
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)
|
||||
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
|
||||
if (a) {
|
||||
this.milkTeaIcon = a.icon
|
||||
this.showMilkTeaPopup = true
|
||||
@@ -55,13 +51,13 @@ export default {
|
||||
// ignore network errors
|
||||
}
|
||||
},
|
||||
closeMilkTeaPopup () {
|
||||
closeMilkTeaPopup() {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
this.showMilkTeaPopup = false
|
||||
this.checkNewMedals()
|
||||
},
|
||||
async checkNewMedals () {
|
||||
async checkNewMedals() {
|
||||
if (!process.client) return
|
||||
if (!authState.loggedIn || !authState.userId) return
|
||||
try {
|
||||
@@ -69,7 +65,7 @@ export default {
|
||||
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))
|
||||
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
|
||||
if (m.length > 0) {
|
||||
this.newMedals = m
|
||||
this.showMedalPopup = true
|
||||
@@ -79,14 +75,13 @@ export default {
|
||||
// ignore errors
|
||||
}
|
||||
},
|
||||
closeMedalPopup () {
|
||||
closeMedalPopup() {
|
||||
if (!process.client) return
|
||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||
this.newMedals.forEach(m => seen.add(m.type))
|
||||
this.newMedals.forEach((m) => seen.add(m.type))
|
||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||
this.showMedalPopup = false
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
</div>
|
||||
<div class="logo-container" @click="goToHome">
|
||||
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
width="60" height="60">
|
||||
<img
|
||||
alt="OpenIsle"
|
||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
width="60"
|
||||
height="60"
|
||||
/>
|
||||
<div class="logo-text">OpenIsle</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +27,7 @@
|
||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,7 +42,7 @@
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
|
||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||
</div>
|
||||
</header>
|
||||
@@ -59,14 +63,14 @@ export default {
|
||||
props: {
|
||||
showMenuBtn: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
avatar: '',
|
||||
showSearch: false,
|
||||
searchDropdown: null
|
||||
searchDropdown: null,
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
@@ -124,7 +128,7 @@ export default {
|
||||
const headerMenuItems = computed(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
{ text: '退出', onClick: goToLogout }
|
||||
{ text: '退出', onClick: goToLogout },
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -139,7 +143,7 @@ export default {
|
||||
goToSettings,
|
||||
goToProfile,
|
||||
goToSignup,
|
||||
goToLogout
|
||||
goToLogout,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -163,15 +167,21 @@ export default {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
|
||||
watch(() => authState.loggedIn, async () => {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
})
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
async () => {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
},
|
||||
)
|
||||
|
||||
watch(() => this.$route.fullPath, () => {
|
||||
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
||||
this.showSearch = false
|
||||
})
|
||||
watch(
|
||||
() => this.$route.fullPath,
|
||||
() => {
|
||||
if (this.$refs.userMenu) this.$refs.userMenu.close()
|
||||
this.showSearch = false
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -257,7 +267,6 @@ export default {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.header-content-item-main:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
@@ -318,5 +327,4 @@ export default {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -18,16 +18,16 @@ export default {
|
||||
props: {
|
||||
exp: { type: Number, default: 0 },
|
||||
currentLevel: { type: Number, default: 0 },
|
||||
nextExp: { type: Number, default: 0 }
|
||||
nextExp: { type: Number, default: 0 },
|
||||
},
|
||||
computed: {
|
||||
max () {
|
||||
max() {
|
||||
return this.nextExp - prevLevelExp(this.currentLevel)
|
||||
},
|
||||
value () {
|
||||
value() {
|
||||
return this.exp - prevLevelExp(this.currentLevel)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,12 +3,8 @@
|
||||
<div class="login-overlay-blur"></div>
|
||||
<div class="login-overlay-content">
|
||||
<i class="fa-solid fa-user login-overlay-icon"></i>
|
||||
<div class="login-overlay-text">
|
||||
请先登录,点击跳转到登录页面
|
||||
</div>
|
||||
<div class="login-overlay-button" @click="goLogin">
|
||||
登录
|
||||
</div>
|
||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,7 +20,7 @@ export default {
|
||||
router.push('/login')
|
||||
}
|
||||
return { goLogin }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,5 +77,4 @@ export default {
|
||||
.login-overlay-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -26,10 +26,10 @@ export default {
|
||||
components: { BasePopup },
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
medals: { type: Array, default: () => [] }
|
||||
medals: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['close'],
|
||||
setup (props, { emit }) {
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const gotoMedals = () => {
|
||||
emit('close')
|
||||
@@ -41,7 +41,7 @@ export default {
|
||||
}
|
||||
const close = () => emit('close')
|
||||
return { gotoMedals, close }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -110,4 +110,3 @@ export default {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
<transition name="slide">
|
||||
<nav v-if="visible" class="menu">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/"
|
||||
@click="handleHomeClick"
|
||||
>
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
@@ -71,9 +66,20 @@
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="c in categoryData" :key="c.id" class="section-item" @click="gotoCategory(c)">
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img v-if="isImageIcon(c.smallIcon || c.icon)" :src="c.smallIcon || c.icon" class="section-item-icon" :alt="c.name" />
|
||||
<img
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
@@ -94,10 +100,16 @@
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<img v-if="isImageIcon(t.smallIcon || t.icon)" :src="t.smallIcon || t.icon" class="section-item-icon" :alt="t.name" />
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text">{{ t.name }} <span class="section-item-text-count">x {{ t.count
|
||||
}}</span></span>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,8 +135,8 @@ export default {
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
async setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
@@ -165,9 +177,7 @@ export default {
|
||||
})
|
||||
|
||||
const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const showUnreadCount = computed(() =>
|
||||
unreadCount.value > 99 ? '99+' : unreadCount.value
|
||||
)
|
||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const updateCount = async () => {
|
||||
@@ -200,19 +210,17 @@ export default {
|
||||
|
||||
const gotoCategory = (c) => {
|
||||
const value = encodeURIComponent(c.id ?? c.name)
|
||||
router
|
||||
.push({ path: '/', query: { category: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
router.push({ path: '/', query: { category: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
const gotoTag = (t) => {
|
||||
const value = encodeURIComponent(t.id ?? t.name)
|
||||
router
|
||||
.push({ path: '/', query: { tags: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
@@ -234,9 +242,9 @@ export default {
|
||||
handleItemClick,
|
||||
isImageIcon,
|
||||
gotoCategory,
|
||||
gotoTag
|
||||
gotoTag,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -380,7 +388,6 @@ export default {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu {
|
||||
position: fixed;
|
||||
|
||||
@@ -7,38 +7,53 @@
|
||||
</div>
|
||||
<div class="milk-tea-description-content">
|
||||
<p>回复帖子每次10exp,最多3次每天</p>
|
||||
<p>发布帖子每次30exp,最多1次每天</p>
|
||||
<p>发表情每次5exp,最多3次每天</p>
|
||||
<p>发布帖子每次30exp,最多1次每天</p>
|
||||
<p>发表情每次5exp,最多3次每天</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="milk-tea-status-container">
|
||||
<div class="milk-tea-status">
|
||||
<div class="status-title">🔥 已兑换奶茶人数</div>
|
||||
<ProgressBar :value="info.redeemCount" :max="50" />
|
||||
<div class="status-text">当前 {{ info.redeemCount }} / 50</div>
|
||||
</div>
|
||||
<div class="milk-tea-status">
|
||||
<div class="status-title">🔥 已兑换奶茶人数</div>
|
||||
<ProgressBar :value="info.redeemCount" :max="50" />
|
||||
<div class="status-text">当前 {{ info.redeemCount }} / 50</div>
|
||||
</div>
|
||||
<div v-if="isLoadingUser" class="loading-user">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
<div class="user-level-text">加载当前等级中...</div>
|
||||
</div>
|
||||
<div v-else-if="user" class="user-level">
|
||||
<LevelProgress :exp="user.experience" :current-level="user.currentLevel" :next-exp="user.nextLevelExp" />
|
||||
<LevelProgress
|
||||
:exp="user.experience"
|
||||
:current-level="user.currentLevel"
|
||||
:next-exp="user.nextLevelExp"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="user-level">
|
||||
<div class="user-level-text"><i class="fas fa-user-circle"></i> 请登录查看自身等级</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="user && user.currentLevel >= 1 && !info.ended" class="redeem-button" @click="openDialog">兑换</div>
|
||||
<div v-else class="redeem-button disabled">兑换</div>
|
||||
<div
|
||||
v-if="user && user.currentLevel >= 1 && !info.ended"
|
||||
class="redeem-button"
|
||||
@click="openDialog"
|
||||
>
|
||||
兑换
|
||||
</div>
|
||||
<div v-else class="redeem-button disabled">兑换</div>
|
||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput textarea="" rows="5" v-model="contact" placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)" />
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
||||
</div>
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea=""
|
||||
rows="5"
|
||||
v-model="contact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,7 +68,7 @@ import { getToken, fetchCurrentUser } from '../utils/auth'
|
||||
export default {
|
||||
name: 'MilkTeaActivityComponent',
|
||||
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
info: { redeemCount: 0, ended: false },
|
||||
user: null,
|
||||
@@ -63,26 +78,26 @@ export default {
|
||||
isLoadingUser: true,
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
async mounted() {
|
||||
await this.loadInfo()
|
||||
this.isLoadingUser = true
|
||||
this.user = await fetchCurrentUser()
|
||||
this.isLoadingUser = false
|
||||
},
|
||||
methods: {
|
||||
async loadInfo () {
|
||||
async loadInfo() {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||
if (res.ok) {
|
||||
this.info = await res.json()
|
||||
}
|
||||
},
|
||||
openDialog () {
|
||||
openDialog() {
|
||||
this.dialogVisible = true
|
||||
},
|
||||
closeDialog () {
|
||||
closeDialog() {
|
||||
this.dialogVisible = false
|
||||
},
|
||||
async submitRedeem () {
|
||||
async submitRedeem() {
|
||||
if (!this.contact) return
|
||||
this.loading = true
|
||||
const token = getToken()
|
||||
@@ -90,9 +105,9 @@ export default {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ contact: this.contact })
|
||||
body: JSON.stringify({ contact: this.contact }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
@@ -107,8 +122,8 @@ export default {
|
||||
toast.error('兑换失败')
|
||||
}
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -161,7 +176,6 @@ export default {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
|
||||
.milk-tea-status-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -228,9 +242,8 @@ export default {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.milk-tea-status-container {
|
||||
.milk-tea-status-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
@@ -240,6 +253,4 @@ export default {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -18,14 +18,14 @@ export default {
|
||||
name: 'NotificationContainer',
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
markRead: { type: Function, required: true }
|
||||
markRead: { type: Function, required: true },
|
||||
},
|
||||
setup() {
|
||||
const isMobile = useIsMobile()
|
||||
return {
|
||||
isMobile
|
||||
isMobile,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -60,5 +60,4 @@ export default {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { themeState } from '../utils/theme'
|
||||
import {
|
||||
createVditor,
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil
|
||||
getPreviewTheme as getPreviewThemeUtil,
|
||||
} from '../utils/vditor'
|
||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||
|
||||
@@ -23,20 +23,20 @@ export default {
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
editorId: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const vditorInstance = ref(null)
|
||||
@@ -56,42 +56,42 @@ export default {
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
val => {
|
||||
(val) => {
|
||||
if (!vditorRender) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
val => {
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.loading) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
(val) => {
|
||||
if (vditorInstance.value && vditorInstance.value.getValue() !== val) {
|
||||
vditorInstance.value.setValue(val)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => themeState.mode,
|
||||
() => {
|
||||
applyTheme()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -109,7 +109,7 @@ export default {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
},
|
||||
})
|
||||
// applyTheme()
|
||||
})
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
})
|
||||
|
||||
return { editorId }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,5 +148,4 @@ export default {
|
||||
min-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<Dropdown v-model="selected" :fetch-options="fetchTypes" placeholder="选择帖子类型" :initial-options="providedOptions" />
|
||||
<Dropdown
|
||||
v-model="selected"
|
||||
:fetch-options="fetchTypes"
|
||||
placeholder="选择帖子类型"
|
||||
:initial-options="providedOptions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -11,7 +16,7 @@ export default {
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: String, default: 'NORMAL' },
|
||||
options: { type: Array, default: () => [] }
|
||||
options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
@@ -19,28 +24,26 @@ export default {
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
val => {
|
||||
(val) => {
|
||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const fetchTypes = async () => {
|
||||
return [
|
||||
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' }
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' },
|
||||
]
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
return { fetchTypes, selected, providedOptions }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -9,15 +9,15 @@ export default {
|
||||
name: 'ProgressBar',
|
||||
props: {
|
||||
value: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 100 }
|
||||
max: { type: Number, default: 100 },
|
||||
},
|
||||
computed: {
|
||||
percent () {
|
||||
percent() {
|
||||
if (this.max <= 0) return 0
|
||||
const p = (this.value / this.max) * 100
|
||||
return Math.max(0, Math.min(100, p))
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<template>
|
||||
<div class="reactions-container">
|
||||
<div class="reactions-viewer">
|
||||
<div class="reactions-viewer-item-container" @click="openPanel" @mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide">
|
||||
<div
|
||||
class="reactions-viewer-item-container"
|
||||
@click="openPanel"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<template v-if="displayedReactions.length">
|
||||
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">{{ reactionEmojiMap[r.type] }}</div>
|
||||
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">
|
||||
{{ reactionEmojiMap[r.type] }}
|
||||
</div>
|
||||
<div class="reactions-count">{{ totalCount }}</div>
|
||||
</template>
|
||||
<div v-else class="reactions-viewer-item placeholder">
|
||||
@@ -21,9 +27,19 @@
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="panelVisible" class="reactions-panel" @mouseenter="cancelHide" @mouseleave="scheduleHide">
|
||||
<div v-for="t in panelTypes" :key="t" class="reaction-option" @click="toggleReaction(t)"
|
||||
:class="{ selected: userReacted(t) }">
|
||||
<div
|
||||
v-if="panelVisible"
|
||||
class="reactions-panel"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<div
|
||||
v-for="t in panelTypes"
|
||||
:key="t"
|
||||
class="reaction-option"
|
||||
@click="toggleReaction(t)"
|
||||
:class="{ selected: userReacted(t) }"
|
||||
>
|
||||
{{ reactionEmojiMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +58,7 @@ const fetchTypes = async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' }
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
if (res.ok) {
|
||||
cachedTypes = await res.json()
|
||||
@@ -60,12 +76,15 @@ export default {
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
contentType: { type: String, required: true },
|
||||
contentId: { type: [Number, 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)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => (reactions.value = v),
|
||||
)
|
||||
|
||||
const reactionTypes = ref([])
|
||||
onMounted(async () => {
|
||||
@@ -83,7 +102,8 @@ export default {
|
||||
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 userReacted = (type) =>
|
||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||
|
||||
const displayedReactions = computed(() => {
|
||||
return Object.entries(counts.value)
|
||||
@@ -92,7 +112,7 @@ export default {
|
||||
.map(([type]) => ({ type }))
|
||||
})
|
||||
|
||||
const panelTypes = computed(() => reactionTypes.value.filter(t => t !== 'LIKE'))
|
||||
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
|
||||
|
||||
const panelVisible = ref(false)
|
||||
let hideTimer = null
|
||||
@@ -102,7 +122,9 @@ export default {
|
||||
}
|
||||
const scheduleHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => { panelVisible.value = false }, 500)
|
||||
hideTimer = setTimeout(() => {
|
||||
panelVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
const cancelHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
@@ -114,12 +136,15 @@ export default {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const url = props.contentType === 'post'
|
||||
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
||||
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||
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)
|
||||
const existingIdx = reactions.value.findIndex(
|
||||
(r) => r.type === type && r.user === authState.username,
|
||||
)
|
||||
let tempReaction = null
|
||||
let removedReaction = null
|
||||
if (existingIdx > -1) {
|
||||
@@ -134,7 +159,7 @@ export default {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ type })
|
||||
body: JSON.stringify({ type }),
|
||||
})
|
||||
if (res.ok) {
|
||||
if (res.status === 204) {
|
||||
@@ -188,9 +213,9 @@ export default {
|
||||
scheduleHide,
|
||||
cancelHide,
|
||||
toggleReaction,
|
||||
userReacted
|
||||
userReacted,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<template>
|
||||
<div class="search-dropdown">
|
||||
<Dropdown ref="dropdown" v-model="selected" :fetch-options="fetchResults" remote menu-class="search-menu"
|
||||
option-class="search-option" :show-search="isMobile" @update:search="keyword = $event" @close="onClose">
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
v-model="selected"
|
||||
:fetch-options="fetchResults"
|
||||
remote
|
||||
menu-class="search-menu"
|
||||
option-class="search-option"
|
||||
:show-search="isMobile"
|
||||
@update:search="keyword = $event"
|
||||
@close="onClose"
|
||||
>
|
||||
<template #display="{ setSearch }">
|
||||
<div class="search-input">
|
||||
<i class="search-input-icon fas fa-search"></i>
|
||||
<input class="text-input" v-model="keyword" placeholder="Search" @input="setSearch(keyword)" />
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
placeholder="Search"
|
||||
@input="setSearch(keyword)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
@@ -53,13 +67,13 @@ export default {
|
||||
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 => ({
|
||||
results.value = data.map((r) => ({
|
||||
id: r.id,
|
||||
text: r.text,
|
||||
type: r.type,
|
||||
subText: r.subText,
|
||||
extra: r.extra,
|
||||
postId: r.postId
|
||||
postId: r.postId,
|
||||
}))
|
||||
return results.value
|
||||
}
|
||||
@@ -68,8 +82,8 @@ export default {
|
||||
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 res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
||||
return res
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
@@ -77,12 +91,12 @@ export default {
|
||||
post: 'fas fa-file-alt',
|
||||
comment: 'fas fa-comment',
|
||||
category: 'fas fa-folder',
|
||||
tag: 'fas fa-hashtag'
|
||||
tag: 'fas fa-hashtag',
|
||||
}
|
||||
|
||||
watch(selected, val => {
|
||||
watch(selected, (val) => {
|
||||
if (!val) return
|
||||
const opt = results.value.find(r => r.id === val)
|
||||
const opt = results.value.find((r) => r.id === val)
|
||||
if (!opt) return
|
||||
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||
router.push(`/posts/${opt.id}`)
|
||||
@@ -101,8 +115,18 @@ export default {
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
return { keyword, selected, fetchResults, highlight, iconMap, isMobile, dropdown, onClose, toggle }
|
||||
}
|
||||
return {
|
||||
keyword,
|
||||
selected,
|
||||
fetchResults,
|
||||
highlight,
|
||||
iconMap,
|
||||
isMobile,
|
||||
dropdown,
|
||||
onClose,
|
||||
toggle,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
<template>
|
||||
<Dropdown v-model="selected" :fetch-options="fetchTags" multiple placeholder="选择标签" remote
|
||||
:initial-options="mergedOptions">
|
||||
<Dropdown
|
||||
v-model="selected"
|
||||
:fetch-options="fetchTags"
|
||||
multiple
|
||||
placeholder="选择标签"
|
||||
remote
|
||||
:initial-options="mergedOptions"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-main">
|
||||
<template v-if="option.icon">
|
||||
<img v-if="isImageIcon(option.icon)" :src="option.icon" class="option-icon" :alt="option.name" />
|
||||
<img
|
||||
v-if="isImageIcon(option.icon)"
|
||||
:src="option.icon"
|
||||
class="option-icon"
|
||||
:alt="option.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
@@ -28,7 +39,7 @@ export default {
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
creatable: { type: Boolean, default: false },
|
||||
options: { type: Array, default: () => [] }
|
||||
options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
@@ -37,63 +48,66 @@ export default {
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
val => {
|
||||
(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)
|
||||
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||
})
|
||||
|
||||
const isImageIcon = icon => {
|
||||
const isImageIcon = (icon) => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
const buildTagsUrl = (kw = '') => {
|
||||
const base = API_BASE_URL || (process.client ? window.location.origin : '');
|
||||
const url = new URL('/api/tags', base);
|
||||
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');
|
||||
if (kw) url.searchParams.set('keyword', kw)
|
||||
url.searchParams.set('limit', '10')
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const fetchTags = async (kw = '') => {
|
||||
const defaultOption = { id: 0, name: '无标签' };
|
||||
const defaultOption = { id: 0, name: '无标签' }
|
||||
|
||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||
const url = buildTagsUrl(kw);
|
||||
const url = buildTagsUrl(kw)
|
||||
|
||||
// 2) 拉数据
|
||||
let data = [];
|
||||
let data = []
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) data = await res.json();
|
||||
const res = await fetch(url)
|
||||
if (res.ok) data = await res.json()
|
||||
} catch {
|
||||
toast.error('获取标签失败');
|
||||
toast.error('获取标签失败')
|
||||
}
|
||||
|
||||
// 3) 合并、去重、可创建
|
||||
let options = [...data, ...localTags.value];
|
||||
let options = [...data, ...localTags.value]
|
||||
|
||||
if (props.creatable && kw &&
|
||||
!options.some(t => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` });
|
||||
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());
|
||||
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
||||
|
||||
// 4) 最终结果
|
||||
return [defaultOption, ...options];
|
||||
};
|
||||
return [defaultOption, ...options]
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => {
|
||||
set: (v) => {
|
||||
if (Array.isArray(v)) {
|
||||
if (v.includes(0)) {
|
||||
emit('update:modelValue', [])
|
||||
@@ -103,11 +117,11 @@ export default {
|
||||
toast.error('最多选择两个标签')
|
||||
return
|
||||
}
|
||||
v = v.map(id => {
|
||||
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)) {
|
||||
if (!localTags.value.find((t) => t.id === newId)) {
|
||||
localTags.value.push({ id: newId, name })
|
||||
}
|
||||
return newId
|
||||
@@ -116,11 +130,11 @@ export default {
|
||||
})
|
||||
}
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { fetchTags, selected, isImageIcon, mergedOptions }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ export default {
|
||||
name: 'UserList',
|
||||
components: { BasePlaceholder },
|
||||
props: {
|
||||
users: { type: Array, default: () => [] }
|
||||
users: { type: Array, default: () => [] },
|
||||
},
|
||||
methods: {
|
||||
handleUserClick(user) {
|
||||
this.$router.push(`/users/${user.id}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -59,5 +59,4 @@ export default {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user