增加积分系统

This commit is contained in:
WilliamColton
2025-08-07 16:29:14 +08:00
21 changed files with 464 additions and 178 deletions
@@ -77,8 +77,8 @@ public class SecurityConfig {
"http://127.0.0.1", "http://127.0.0.1",
"http://localhost:8080", "http://localhost:8080",
"http://localhost", "http://localhost",
"http://30.211.98.193:8080", "http://30.211.106.178:8080",
"http://30.211.98.193", "http://30.211.106.178",
"http://192.168.7.70", "http://192.168.7.70",
"http://192.168.7.70:8080", "http://192.168.7.70:8080",
websiteUrl, websiteUrl,
@@ -62,7 +62,7 @@ public class PointService {
isTheRewardCapped = true; isTheRewardCapped = true;
} }
// 如果发帖人与评论者是同一个,则只计算发帖加分 // 如果发帖人与评论者是同一个,则只计算单次加分
if (poster.getId().equals(commenter.getId())) { if (poster.getId().equals(commenter.getId())) {
if (isTheRewardCapped) { if (isTheRewardCapped) {
return 0; return 0;
@@ -71,14 +71,18 @@ public class PointService {
pointLogRepository.save(log); pointLogRepository.save(log);
return addPoint(commenter, 10); return addPoint(commenter, 10);
} }
} else {
addPoint(poster, 10);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
return addPoint(commenter, 10);
}
} }
// 如果不是同一个,则为发帖人和评论者同时加分
addPoint(poster, 10);
return addPoint(commenter, 10);
} }
// 考虑点赞者和发帖人是同一个的情况 // 需要考虑点赞者和发帖人是同一个的情况
public int awardForReactionOfPost(String reactionerName, Long postId) { public int awardForReactionOfPost(String reactionerName, Long postId) {
// 根据帖子id找到发帖人 // 根据帖子id找到发帖人
User poster = postRepository.findById(postId).orElseThrow().getAuthor(); User poster = postRepository.findById(postId).orElseThrow().getAuthor();
+1
View File
@@ -11,6 +11,7 @@
"core-js": "^3.8.3", "core-js": "^3.8.3",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"highlight.js": "^10.7.1",
"ldrs": "^1.1.7", "ldrs": "^1.1.7",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"vditor": "^3.11.1", "vditor": "^3.11.1",
+26 -1
View File
@@ -66,6 +66,21 @@ body {
.vditor { .vditor {
min-height: 200px; min-height: 200px;
} }
.vditor-toolbar--pin {
top: var(--header-height) !important;
}
.vditor-panel {
min-width: 400px;
}
.emoji {
width: 20px;
height: 20px;
vertical-align: middle;
}
/* .vditor { /* .vditor {
--textarea-background-color: transparent; --textarea-background-color: transparent;
border: none !important; border: none !important;
@@ -220,7 +235,7 @@ body {
} }
.vditor-toolbar { .vditor-toolbar {
display: none; overflow-x: auto;
} }
.about-content h1, .about-content h1,
@@ -240,6 +255,11 @@ body {
margin-bottom: 3px; margin-bottom: 3px;
} }
.vditor-toolbar--pin {
top: 0 !important;
}
.about-content li, .about-content li,
.info-content-text li { .info-content-text li {
font-size: 14px; font-size: 14px;
@@ -248,4 +268,9 @@ body {
.info-content-text pre { .info-content-text pre {
line-height: 1.1; line-height: 1.1;
} }
.vditor-panel {
position: relative;
min-width: 0;
}
} }
+5 -3
View File
@@ -67,9 +67,11 @@ export default {
if (!vditorInstance.value || isDisabled.value) return if (!vditorInstance.value || isDisabled.value) return
const value = vditorInstance.value.getValue() const value = vditorInstance.value.getValue()
console.debug('CommentEditor submit', value) console.debug('CommentEditor submit', value)
emit('submit', value) emit('submit', value, () => {
vditorInstance.value.setValue('') if (!vditorInstance.value) return
text.value = '' vditorInstance.value.setValue('')
text.value = ''
})
} }
onMounted(() => { onMounted(() => {
+2 -1
View File
@@ -157,7 +157,7 @@ const CommentItem = {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const submitReply = async (text) => { const submitReply = async (text, clear) => {
if (!text.trim()) return if (!text.trim()) return
isWaitingForReply.value = true isWaitingForReply.value = true
const token = getToken() const token = getToken()
@@ -201,6 +201,7 @@ const CommentItem = {
src: data.author.avatar, src: data.author.avatar,
iconClick: () => router.push(`/users/${data.author.id}`) iconClick: () => router.push(`/users/${data.author.id}`)
}) })
clear()
showEditor.value = false showEditor.value = false
toast.success('回复成功') toast.success('回复成功')
} else if (res.status === 429) { } else if (res.status === 429) {
+108 -54
View File
@@ -1,13 +1,24 @@
<template> <template>
<div class="dropdown" ref="wrapper"> <div class="dropdown" ref="wrapper">
<div class="dropdown-display" @click="toggle"> <div class="dropdown-display" @click="toggle">
<slot name="display" :selected="selectedLabels" :toggle="toggle" :search="search" :setSearch="setSearch"> <slot
name="display"
:selected="selectedLabels"
:toggle="toggle"
:search="search"
:setSearch="setSearch"
>
<template v-if="multiple"> <template v-if="multiple">
<span v-if="selectedLabels.length"> <span v-if="selectedLabels.length">
<template v-for="(label, idx) in selectedLabels" :key="label.id"> <template v-for="(label, idx) in selectedLabels" :key="label.id">
<div class="selected-label"> <div class="selected-label">
<template v-if="label.icon"> <template v-if="label.icon">
<img v-if="isImageIcon(label.icon)" :src="label.icon" class="option-icon" :alt="label.name" /> <img
v-if="isImageIcon(label.icon)"
:src="label.icon"
class="option-icon"
:alt="label.name"
/>
<i v-else :class="['option-icon', label.icon]"></i> <i v-else :class="['option-icon', label.icon]"></i>
</template> </template>
<span>{{ label.name }}</span> <span>{{ label.name }}</span>
@@ -21,7 +32,12 @@
<span v-if="selectedLabels.length"> <span v-if="selectedLabels.length">
<div class="selected-label"> <div class="selected-label">
<template v-if="selectedLabels[0].icon"> <template v-if="selectedLabels[0].icon">
<img v-if="isImageIcon(selectedLabels[0].icon)" :src="selectedLabels[0].icon" class="option-icon" :alt="selectedLabels[0].name" /> <img
v-if="isImageIcon(selectedLabels[0].icon)"
:src="selectedLabels[0].icon"
class="option-icon"
:alt="selectedLabels[0].name"
/>
<i v-else :class="['option-icon', selectedLabels[0].icon]"></i> <i v-else :class="['option-icon', selectedLabels[0].icon]"></i>
</template> </template>
<span>{{ selectedLabels[0].name }}</span> <span>{{ selectedLabels[0].name }}</span>
@@ -32,20 +48,46 @@
<i class="fas fa-caret-down dropdown-caret"></i> <i class="fas fa-caret-down dropdown-caret"></i>
</slot> </slot>
</div> </div>
<div v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)" :class="['dropdown-menu', menuClass]"> <div
v-if="
open &&
!isMobile &&
(loading || filteredOptions.length > 0 || showSearch)
"
:class="['dropdown-menu', menuClass]"
v-click-outside="close"
>
<div v-if="showSearch" class="dropdown-search"> <div v-if="showSearch" class="dropdown-search">
<i class="fas fa-search search-icon"></i> <i class="fas fa-search search-icon"></i>
<input type="text" v-model="search" placeholder="搜索" /> <input type="text" v-model="search" placeholder="搜索" />
</div> </div>
<div v-if="loading" class="dropdown-loading"> <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> </div>
<template v-else> <template v-else>
<div v-for="o in filteredOptions" :key="o.id" @click="select(o.id)" <div
:class="['dropdown-option', optionClass, { 'selected': isSelected(o.id) }]"> v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="[
'dropdown-option',
optionClass,
{ selected: isSelected(o.id) },
]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)"> <slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon"> <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> <i v-else :class="['option-icon', o.icon]"></i>
</template> </template>
<span>{{ o.name }}</span> <span>{{ o.name }}</span>
@@ -65,14 +107,32 @@
<input type="text" v-model="search" placeholder="搜索" /> <input type="text" v-model="search" placeholder="搜索" />
</div> </div>
<div v-if="loading" class="dropdown-loading"> <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> </div>
<template v-else> <template v-else>
<div v-for="o in filteredOptions" :key="o.id" @click="select(o.id)" <div
:class="['dropdown-option', optionClass, { 'selected': isSelected(o.id) }]"> v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="[
'dropdown-option',
optionClass,
{ selected: isSelected(o.id) },
]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)"> <slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon"> <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> <i v-else :class="['option-icon', o.icon]"></i>
</template> </template>
<span>{{ o.name }}</span> <span>{{ o.name }}</span>
@@ -86,47 +146,49 @@
</template> </template>
<script> <script>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted } from "vue"
import { hatch } from 'ldrs' import { hatch } from "ldrs"
import { isMobile } from '../utils/screen' import { isMobile } from "../utils/screen"
hatch.register() hatch.register()
export default { export default {
name: 'BaseDropdown', name: "BaseDropdown",
props: { props: {
modelValue: { type: [Array, String, Number], default: () => [] }, modelValue: { type: [Array, String, Number], default: () => [] },
placeholder: { type: String, default: '返回' }, placeholder: { type: String, default: "返回" },
multiple: { type: Boolean, default: false }, multiple: { type: Boolean, default: false },
fetchOptions: { type: Function, required: true }, fetchOptions: { type: Function, required: true },
remote: { type: Boolean, default: false }, remote: { type: Boolean, default: false },
menuClass: { type: String, default: '' }, menuClass: { type: String, default: "" },
optionClass: { type: String, default: '' }, optionClass: { type: String, default: "" },
showSearch: { type: Boolean, default: true }, showSearch: { type: Boolean, default: true },
initialOptions: { type: Array, default: () => [] } initialOptions: { type: Array, default: () => [] },
}, },
emits: ['update:modelValue', 'update:search', 'close'], emits: ["update:modelValue", "update:search", "close"],
setup(props, { emit, expose }) { setup(props, { emit, expose }) {
const open = ref(false) const open = ref(false)
const search = ref('') const search = ref("")
const setSearch = (val) => { const setSearch = (val) => {
search.value = 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 loaded = ref(false)
const loading = ref(false) const loading = ref(false)
const wrapper = ref(null) const wrapper = ref(null)
const toggle = () => { const toggle = () => {
open.value = !open.value open.value = !open.value
if (!open.value) emit('close') if (!open.value) emit("close")
} }
const close = () => { const close = () => {
open.value = false open.value = false
emit('close') emit("close")
} }
const select = id => { const select = (id) => {
if (props.multiple) { if (props.multiple) {
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : [] const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const idx = arr.indexOf(id) const idx = arr.indexOf(id)
@@ -135,28 +197,23 @@ export default {
} else { } else {
arr.push(id) arr.push(id)
} }
emit('update:modelValue', arr) emit("update:modelValue", arr)
} else { } else {
emit('update:modelValue', id) emit("update:modelValue", id)
close() close()
} }
search.value = '' search.value = ""
} }
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
if (props.remote) return options.value if (props.remote) return options.value
if (!search.value) 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 clickOutside = e => { const loadOptions = async (kw = "") => {
if (isMobile) return
if (wrapper.value && !wrapper.value.contains(e.target)) {
close()
}
}
const loadOptions = async (kw = '') => {
if (!props.remote && loaded.value) return if (!props.remote && loaded.value) return
try { try {
loading.value = true loading.value = true
@@ -172,14 +229,14 @@ export default {
watch( watch(
() => props.initialOptions, () => props.initialOptions,
val => { (val) => {
if (Array.isArray(val)) { if (Array.isArray(val)) {
options.value = [...val] options.value = [...val]
} }
} }
) )
watch(open, async val => { watch(open, async (val) => {
if (val) { if (val) {
if (props.remote) { if (props.remote) {
await loadOptions(search.value) await loadOptions(search.value)
@@ -189,39 +246,36 @@ export default {
} }
}) })
watch(search, async val => { watch(search, async (val) => {
emit('update:search', val) emit("update:search", val)
if (props.remote && open.value) { if (props.remote && open.value) {
await loadOptions(val) await loadOptions(val)
} }
}) })
onMounted(() => { onMounted(() => {
document.addEventListener('click', clickOutside)
if (!props.remote) { if (!props.remote) {
loadOptions() loadOptions()
} }
}) })
onBeforeUnmount(() => {
document.removeEventListener('click', clickOutside)
})
const selectedLabels = computed(() => { const selectedLabels = computed(() => {
if (props.multiple) { 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) const match = options.value.find((o) => o.id === props.modelValue)
return match ? [match] : [] return match ? [match] : []
}) })
const isSelected = (id) => { const isSelected = (id) => {
return selectedLabels.value.some(label => label.id === id) return selectedLabels.value.some((label) => label.id === id)
} }
const isImageIcon = icon => { const isImageIcon = (icon) => {
if (!icon) return false if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/') return /^https?:\/\//.test(icon) || icon.startsWith("/")
} }
expose({ toggle, close }) expose({ toggle, close })
@@ -239,9 +293,9 @@ export default {
loading, loading,
isImageIcon, isImageIcon,
setSearch, setSearch,
isMobile isMobile,
} }
} },
} }
</script> </script>
+15 -3
View File
@@ -19,10 +19,9 @@ import { clearVditorStorage } from '../utils/clearVditorStorage'
import { hatch } from 'ldrs' import { hatch } from 'ldrs'
hatch.register() hatch.register()
export default { export default {
name: 'PostEditor', name: 'PostEditor',
emits: ['update:modelValue'], emits: ['update:modelValue', 'update:loading'],
props: { props: {
modelValue: { modelValue: {
type: String, type: String,
@@ -43,6 +42,8 @@ export default {
}, },
setup(props, { emit }) { setup(props, { emit }) {
const vditorInstance = ref(null) const vditorInstance = ref(null)
let vditorRender = false
const getEditorTheme = getEditorThemeUtil const getEditorTheme = getEditorThemeUtil
const getPreviewTheme = getPreviewThemeUtil const getPreviewTheme = getPreviewThemeUtil
const applyTheme = () => { const applyTheme = () => {
@@ -54,6 +55,7 @@ export default {
watch( watch(
() => props.loading, () => props.loading,
val => { val => {
if (!vditorRender) return
if (val) { if (val) {
vditorInstance.value.disabled() vditorInstance.value.disabled()
} else { } else {
@@ -91,12 +93,15 @@ export default {
) )
onMounted(() => { onMounted(() => {
emit('update:loading', true)
vditorInstance.value = createVditor(props.editorId, { vditorInstance.value = createVditor(props.editorId, {
placeholder: '请输入正文...', placeholder: '请输入正文...',
input(value) { input(value) {
emit('update:modelValue', value) emit('update:modelValue', value)
}, },
after() { after() {
vditorRender = true
emit('update:loading', false)
vditorInstance.value.setValue(props.modelValue) vditorInstance.value.setValue(props.modelValue)
if (props.loading || props.disabled) { if (props.loading || props.disabled) {
vditorInstance.value.disabled() vditorInstance.value.disabled()
@@ -118,8 +123,8 @@ export default {
<style scoped> <style scoped>
.post-editor-container { .post-editor-container {
border: 1px solid var(--normal-border-color);
position: relative; position: relative;
min-height: 200px;
} }
.editor-loading-overlay { .editor-loading-overlay {
@@ -135,4 +140,11 @@ export default {
pointer-events: all; pointer-events: all;
z-index: 10; z-index: 10;
} }
@media (max-width: 768px) {
.post-editor-container {
min-height: 100px;
}
}
</style> </style>
+4 -29
View File
@@ -4,7 +4,7 @@
<div class="reactions-viewer-item-container" @click="openPanel" @mouseenter="cancelHide" <div class="reactions-viewer-item-container" @click="openPanel" @mouseenter="cancelHide"
@mouseleave="scheduleHide"> @mouseleave="scheduleHide">
<template v-if="displayedReactions.length"> <template v-if="displayedReactions.length">
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">{{ iconMap[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> <div class="reactions-count">{{ totalCount }}</div>
</template> </template>
<div v-else class="reactions-viewer-item placeholder"> <div v-else class="reactions-viewer-item placeholder">
@@ -24,7 +24,7 @@
<div v-if="panelVisible" class="reactions-panel" @mouseenter="cancelHide" @mouseleave="scheduleHide"> <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)" <div v-for="t in panelTypes" :key="t" class="reaction-option" @click="toggleReaction(t)"
:class="{ selected: userReacted(t) }"> :class="{ selected: userReacted(t) }">
{{ iconMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span> {{ reactionEmojiMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -34,6 +34,7 @@
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { API_BASE_URL, toast } from '../main' import { API_BASE_URL, toast } from '../main'
import { getToken, authState } from '../utils/auth' import { getToken, authState } from '../utils/auth'
import { reactionEmojiMap } from '../utils/reactions'
let cachedTypes = null let cachedTypes = null
const fetchTypes = async () => { const fetchTypes = async () => {
@@ -54,32 +55,6 @@ const fetchTypes = async () => {
return cachedTypes return cachedTypes
} }
const iconMap = {
LIKE: '❤️',
DISLIKE: '👎',
RECOMMEND: '👏',
ANGRY: '😡',
FLUSHED: '😳',
STAR_STRUCK: '🤩',
ROFL: '🤣',
HOLDING_BACK_TEARS: '🥹',
MIND_BLOWN: '🤯',
POOP: '💩',
CLOWN: '🤡',
SKULL: '☠️',
FIRE: '🔥',
EYES: '👀',
FROWN: '☹️',
HOT: '🥵',
EAGLE: '🦅',
SPIDER: '🕷️',
BAT: '🦇',
CHINA: '🇨🇳',
USA: '🇺🇸',
JAPAN: '🇯🇵',
KOREA: '🇰🇷'
}
export default { export default {
name: 'ReactionsGroup', name: 'ReactionsGroup',
props: { props: {
@@ -202,7 +177,7 @@ export default {
} }
return { return {
iconMap, reactionEmojiMap,
counts, counts,
totalCount, totalCount,
likeCount, likeCount,
+159
View File
@@ -0,0 +1,159 @@
/**
* @file clickOutsideDirective.js
* @description 一个用于检测元素外部点击的Vue 3自定义指令。
*
* @example
* // 在 main.js 中全局注册
* import { createApp } from 'vue'
* import App from './App.vue'
* import ClickOutside from './clickOutsideDirective.js'
*
* const app = createApp(App)
* app.directive('click-outside', ClickOutside)
* app.mount('#app')
*
* // 在组件中使用
* <div v-click-outside="myMethod">...</div>
*
* // 排除特定元素
* <div v-click-outside:[myExcludedElement]="myMethod">...</div>
* <div v-click-outside:[[el1, el2]]="myMethod">...</div>
*/
// 使用一个Map来存储所有指令绑定的元素及其对应的处理器
// 键是HTMLElement,值是一个包含处理器和回调函数的对象数组
const nodeList = new Map();
// 检查是否在客户端环境,以避免在SSR(服务器端渲染)时执行
const isClient = typeof window !== 'undefined';
// 在客户端环境中,只设置一次全局的 mousedown 和 mouseup 监听器
if (isClient) {
let startClick;
document.addEventListener('mousedown', (e) => (startClick = e));
document.addEventListener('mouseup', (e) => {
// 遍历所有注册的元素和它们的处理器
for (const handlers of nodeList.values()) {
for (const { documentHandler } of handlers) {
// 调用每个处理器,传入 mouseup 和 mousedown 事件
documentHandler(e, startClick);
}
}
// 完成后重置 startClick
startClick = undefined;
});
}
/**
* 创建一个文档事件处理器。
* @param {HTMLElement} el - 指令绑定的元素。
* @param {import('vue').DirectiveBinding} binding - 指令的绑定对象。
* @returns {Function} 返回一个处理函数。
*/
function createDocumentHandler(el, binding) {
let excludes = [];
// binding.arg 可以是一个元素或一个元素数组,用于排除不需要触发回调的点击
if (Array.isArray(binding.arg)) {
excludes = binding.arg;
} else if (binding.arg instanceof HTMLElement) {
excludes.push(binding.arg);
}
return function (mouseup, mousedown) {
// 从组件实例中获取 popper 引用(如果存在),这对于处理下拉菜单、弹窗等很有用
const popperRef = binding.instance?.popperRef;
const mouseUpTarget = mouseup.target;
const mouseDownTarget = mousedown?.target;
// 检查各种条件,如果满足任一条件,则不执行回调
const isBound = !binding || !binding.instance;
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
const isSelf = el === mouseUpTarget;
// 检查点击是否发生在任何被排除的元素内部
const isTargetExcluded =
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
(excludes.length && excludes.includes(mouseDownTarget));
// 检查点击是否发生在关联的 popper 元素内部
const isContainedByPopper =
popperRef &&
(popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
if (
isBound ||
isTargetExists ||
isContainedByEl ||
isSelf ||
isTargetExcluded ||
isContainedByPopper
) {
return;
}
// 如果所有检查都通过,说明点击发生在外部,执行指令传入的回调函数
binding.value(mouseup, mousedown);
};
}
const ClickOutside = {
/**
* 在绑定元素的 attribute 或事件监听器被应用之前调用。
* @param {HTMLElement} el
* @param {import('vue').DirectiveBinding} binding
*/
beforeMount(el, binding) {
if (!nodeList.has(el)) {
nodeList.set(el, []);
}
nodeList.get(el).push({
documentHandler: createDocumentHandler(el, binding),
bindingFn: binding.value,
});
},
/**
* 在包含组件的 VNode 及其子组件的 VNode 更新后调用。
* @param {HTMLElement} el
* @param {import('vue').DirectiveBinding} binding
*/
updated(el, binding) {
if (!nodeList.has(el)) {
nodeList.set(el, []);
}
const handlers = nodeList.get(el);
// 查找旧的回调函数对应的处理器
const oldHandlerIndex = handlers.findIndex(
(item) => item.bindingFn === binding.oldValue
);
const newHandler = {
documentHandler: createDocumentHandler(el, binding),
bindingFn: binding.value,
};
if (oldHandlerIndex >= 0) {
// 如果找到了,就替换成新的处理器
handlers.splice(oldHandlerIndex, 1, newHandler);
} else {
// 否则,直接添加新的处理器
handlers.push(newHandler);
}
},
/**
* 在绑定元素的父组件卸载后调用。
* @param {HTMLElement} el
*/
unmounted(el) {
// 当元素卸载时,从Map中移除它,以进行垃圾回收并防止内存泄漏
nodeList.delete(el);
},
};
export default ClickOutside;
+4
View File
@@ -12,6 +12,8 @@ import { checkToken, clearToken, isLogin } from './utils/auth'
import { loginWithGoogle } from './utils/google' import { loginWithGoogle } from './utils/google'
import { initTheme } from './utils/theme' import { initTheme } from './utils/theme'
import { clearVditorStorage } from './utils/clearVditorStorage' import { clearVditorStorage } from './utils/clearVditorStorage'
import ClickOutside from './directives/clickOutside' // 确保路径正确
// 采用本地开发环境 // 采用本地开发环境
// export const API_DOMAIN = 'http://127.0.0.1' // export const API_DOMAIN = 'http://127.0.0.1'
@@ -48,6 +50,8 @@ app.use(
}, },
) )
app.directive('click-outside', ClickOutside)
app.mount('#app') app.mount('#app')
checkToken().then(valid => { checkToken().then(valid => {
+24
View File
@@ -2,6 +2,7 @@ import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import { toast } from '../main' import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
function mentionPlugin(md) { function mentionPlugin(md) {
const mentionReg = /^@\[([^\]]+)\]/ const mentionReg = /^@\[([^\]]+)\]/
@@ -27,6 +28,28 @@ function mentionPlugin(md) {
md.inline.ruler.before('emphasis', 'mention', mention) md.inline.ruler.before('emphasis', 'mention', mention)
} }
function tiebaEmojiPlugin(md) {
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
const name = tokens[idx].content
const file = tiebaEmoji[name]
return `<img class="emoji" src="${file}" alt="${name}">`
}
md.inline.ruler.before('emphasis', 'tieba-emoji', (state, silent) => {
const pos = state.pos
if (state.src.charCodeAt(pos) !== 0x3a) return false
const match = state.src.slice(pos).match(/^:tieba(\d+):/)
if (!match) return false
const key = `tieba${match[1]}`
if (!tiebaEmoji[key]) return false
if (!silent) {
const token = state.push('tieba-emoji', '', 0)
token.content = key
}
state.pos += match[0].length
return true
})
}
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,
linkify: true, linkify: true,
@@ -43,6 +66,7 @@ const md = new MarkdownIt({
}) })
md.use(mentionPlugin) md.use(mentionPlugin)
md.use(tiebaEmojiPlugin)
export function renderMarkdown(text) { export function renderMarkdown(text) {
return md.render(text || '') return md.render(text || '')
+25
View File
@@ -0,0 +1,25 @@
export const reactionEmojiMap = {
LIKE: '❤️',
DISLIKE: '👎',
RECOMMEND: '👏',
ANGRY: '😡',
FLUSHED: '😳',
STAR_STRUCK: '🤩',
ROFL: '🤣',
HOLDING_BACK_TEARS: '🥹',
MIND_BLOWN: '🤯',
POOP: '💩',
CLOWN: '🤡',
SKULL: '☠️',
FIRE: '🔥',
EYES: '👀',
FROWN: '☹️',
HOT: '🥵',
EAGLE: '🦅',
SPIDER: '🕷️',
BAT: '🦇',
CHINA: '🇨🇳',
USA: '🇺🇸',
JAPAN: '🇯🇵',
KOREA: '🇰🇷'
}
+11
View File
@@ -0,0 +1,11 @@
export const TIEBA_EMOJI_CDN = 'https://cdn.jsdelivr.net/gh/microlong666/tieba_mobile_emotions@master/'
// export const TIEBA_EMOJI_CDN = 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor/dist/images/emoji/'
export const tiebaEmoji = (() => {
const map = { tieba1: TIEBA_EMOJI_CDN + 'image_emoticon.png' }
for (let i = 2; i <= 124; i++) {
if (i > 50 && i < 62) continue
map[`tieba${i}`] = TIEBA_EMOJI_CDN + `image_emoticon${i}.png`
}
return map
})()
+28 -19
View File
@@ -3,6 +3,7 @@ import 'vditor/dist/index.css'
import { API_BASE_URL } from '../main' import { API_BASE_URL } from '../main'
import { getToken, authState } from './auth' import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user' import { searchUsers, fetchFollowings, fetchAdmins } from './user'
import { tiebaEmoji } from './tiebaEmoji'
export function getEditorTheme() { export function getEditorTheme() {
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic' return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
@@ -37,13 +38,38 @@ export function createVditor(editorId, options = {}) {
return searchUsers(value) return searchUsers(value)
} }
const isMobile = window.innerWidth <= 768
const toolbar = isMobile
? ['emoji', 'upload']
: [
'emoji',
'bold',
'italic',
'strike',
'|',
'list',
'line',
'quote',
'code',
'inline-code',
'|',
'undo',
'redo',
'|',
'link',
'upload'
]
let vditor let vditor
vditor = new Vditor(editorId, { vditor = new Vditor(editorId, {
placeholder, placeholder,
height: 'auto', height: 'auto',
theme: getEditorTheme(), theme: getEditorTheme(),
preview: Object.assign({ theme: { current: getPreviewTheme() } }, preview), preview: Object.assign({
theme: { current: getPreviewTheme() },
}, preview),
hint: { hint: {
emoji: tiebaEmoji,
extend: [ extend: [
{ {
key: '@', key: '@',
@@ -58,24 +84,7 @@ export function createVditor(editorId, options = {}) {
], ],
}, },
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor', cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
toolbar: [ toolbar,
'emoji',
'bold',
'italic',
'strike',
'|',
'list',
'line',
'quote',
'code',
'inline-code',
'|',
'undo',
'redo',
'|',
'link',
'upload'
],
upload: { upload: {
accept: 'image/*,video/*', accept: 'image/*,video/*',
multiple: false, multiple: false,
+1 -3
View File
@@ -75,13 +75,11 @@ export default {
max-width: var(--page-max-width); max-width: var(--page-max-width);
background-color: var(--background-color); background-color: var(--background-color);
margin: 0 auto; margin: 0 auto;
height: 100%;
overflow-y: auto;
} }
.about-tabs { .about-tabs {
position: sticky; position: sticky;
top: 1px; top: calc(var(--header-height) + 1px);
z-index: 200; z-index: 200;
background-color: var(--background-color-blur); background-color: var(--background-color-blur);
display: flex; display: flex;
+1 -1
View File
@@ -433,7 +433,7 @@ export default {
.topic-container { .topic-container {
position: sticky; position: sticky;
top: 1px; top: calc(var(--header-height) + 1px);
z-index: 10; z-index: 10;
background-color: var(--background-color-blur); background-color: var(--background-color-blur);
display: flex; display: flex;
+29 -43
View File
@@ -299,11 +299,12 @@ import BaseTimeline from '../components/BaseTimeline.vue'
import BasePlaceholder from '../components/BasePlaceholder.vue' import BasePlaceholder from '../components/BasePlaceholder.vue'
import NotificationContainer from '../components/NotificationContainer.vue' import NotificationContainer from '../components/NotificationContainer.vue'
import { getToken, authState } from '../utils/auth' import { getToken, authState } from '../utils/auth'
import { markNotificationsRead, fetchUnreadCount } from '../utils/notification' import { markNotificationsRead, fetchUnreadCount, notificationState } from '../utils/notification'
import { toast } from '../main' import { toast } from '../main'
import { stripMarkdownLength } from '../utils/markdown' import { stripMarkdownLength } from '../utils/markdown'
import TimeManager from '../utils/time' import TimeManager from '../utils/time'
import { hatch } from 'ldrs' import { hatch } from 'ldrs'
import { reactionEmojiMap } from '../utils/reactions'
hatch.register() hatch.register()
export default { export default {
@@ -322,28 +323,42 @@ export default {
const markRead = async id => { const markRead = async id => {
if (!id) return if (!id) return
const n = notifications.value.find(n => n.id === id)
if (!n || n.read) return
n.read = true
if (notificationState.unreadCount > 0) notificationState.unreadCount--
const ok = await markNotificationsRead([id]) const ok = await markNotificationsRead([id])
if (ok) { if (!ok) {
const n = notifications.value.find(n => n.id === id) n.read = false
if (n) n.read = true notificationState.unreadCount++
await fetchUnreadCount() } else {
fetchUnreadCount()
} }
} }
const markAllRead = async () => { const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息 // 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value.filter(n => n.type !== 'REGISTER_REQUEST').map(n => n.id) const idsToMark = notifications.value
.filter(n => n.type !== 'REGISTER_REQUEST' && !n.read)
.map(n => n.id)
if (idsToMark.length === 0) return
notifications.value.forEach(n => {
if (n.type !== 'REGISTER_REQUEST') n.read = true
})
notificationState.unreadCount = notifications.value.filter(n => !n.read).length
const ok = await markNotificationsRead(idsToMark) const ok = await markNotificationsRead(idsToMark)
if (ok) { if (!ok) {
notifications.value.forEach(n => { notifications.value.forEach(n => {
if (n.type !== 'REGISTER_REQUEST') n.read = true if (idsToMark.includes(n.id)) n.read = false
}) })
await fetchUnreadCount() await fetchUnreadCount()
if (authState.role === 'ADMIN') { return
toast.success('已读所有消息(注册请求除外)') }
} else { fetchUnreadCount()
toast.success('已读所有消息') if (authState.role === 'ADMIN') {
} toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
} }
} }
@@ -364,32 +379,6 @@ export default {
MENTION: 'fas fa-at' MENTION: 'fas fa-at'
} }
const reactionEmojiMap = {
LIKE: '❤️',
DISLIKE: '👎',
RECOMMEND: '👏',
ANGRY: '😡',
FLUSHED: '😳',
STAR_STRUCK: '🤩',
ROFL: '🤣',
HOLDING_BACK_TEARS: '🥹',
MIND_BLOWN: '🤯',
POOP: '💩',
CLOWN: '🤡',
SKULL: '☠️',
FIRE: '🔥',
EYES: '👀',
FROWN: '☹️',
HOT: '🥵',
EAGLE: '🦅',
SPIDER: '🕷️',
BAT: '🦇',
CHINA: '🇨🇳',
USA: '🇺🇸',
JAPAN: '🇯🇵',
KOREA: '🇰🇷'
}
const fetchNotifications = async () => { const fetchNotifications = async () => {
try { try {
const token = getToken() const token = getToken()
@@ -628,6 +617,7 @@ export default {
.message-page { .message-page {
background-color: var(--background-color); background-color: var(--background-color);
overflow-x: hidden;
} }
.message-page-header { .message-page-header {
@@ -769,9 +759,5 @@ export default {
.has_read_button { .has_read_button {
display: none; display: none;
} }
.message-page {
overflow-x: hidden;
}
} }
</style> </style>
+2 -4
View File
@@ -3,8 +3,8 @@
<div class="new-post-form"> <div class="new-post-form">
<input class="post-title-input" v-model="title" placeholder="标题"/> <input class="post-title-input" v-model="title" placeholder="标题"/>
<div class="post-editor-container"> <div class="post-editor-container">
<PostEditor v-model="content" :loading="isAiLoading" :disabled="!isLogin"/> <PostEditor v-model="content" v-model:loading="isAiLoading" :disabled="!isLogin" />
<LoginOverlay v-if="!isLogin"/> <LoginOverlay v-if="!isLogin" />
</div> </div>
<div class="post-options"> <div class="post-options">
<div class="post-options-left"> <div class="post-options-left">
@@ -282,10 +282,8 @@ export default {
display: flex; display: flex;
justify-content: center; justify-content: center;
background-color: var(--background-color); background-color: var(--background-color);
height: 100%;
padding-right: 20px; padding-right: 20px;
padding-left: 20px; padding-left: 20px;
overflow-y: auto;
} }
.new-post-form { .new-post-form {
+2 -1
View File
@@ -375,7 +375,7 @@ export default {
} }
} }
const postComment = async (text) => { const postComment = async (text, clear) => {
if (!text.trim()) return if (!text.trim()) return
console.debug('Posting comment', {postId, text}) console.debug('Posting comment', {postId, text})
isWaitingPostingComment.value = true isWaitingPostingComment.value = true
@@ -396,6 +396,7 @@ export default {
const data = await res.json() const data = await res.json()
console.debug('Post comment response data', data) console.debug('Post comment response data', data)
await fetchComments() await fetchComments()
clear()
const reward = Math.max(0, Number(data?.reward) || 0) // 经验值 const reward = Math.max(0, Number(data?.reward) || 0) // 经验值
const points = Math.max(0, Number(data?.pointReward) || 0) // 积分值 const points = Math.max(0, Number(data?.pointReward) || 0) // 积分值
+1 -4
View File
@@ -497,9 +497,6 @@ export default {
.profile-page { .profile-page {
background-color: var(--background-color); background-color: var(--background-color);
height: 100%;
overflow-y: auto;
overflow-x: hidden;
} }
.profile-page-header { .profile-page-header {
@@ -639,7 +636,7 @@ export default {
.profile-tabs { .profile-tabs {
position: sticky; position: sticky;
top: 1px; top: calc(var(--header-height) + 1px);
z-index: 200; z-index: 200;
background-color: var(--background-color-blur); background-color: var(--background-color-blur);
display: flex; display: flex;