mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-08-11 13:50:59 +00:00
增加积分系统
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -65,7 +65,22 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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'
|
||||||
@@ -38,16 +40,18 @@ clearVditorStorage()
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(
|
app.use(
|
||||||
Toast,
|
Toast,
|
||||||
{
|
{
|
||||||
position: POSITION.TOP_RIGHT,
|
position: POSITION.TOP_RIGHT,
|
||||||
containerClassName: "open-isle-toast-style-v1",
|
containerClassName: "open-isle-toast-style-v1",
|
||||||
transition: "Vue-Toastification__fade",
|
transition: "Vue-Toastification__fade",
|
||||||
// closeButton: false,
|
// closeButton: false,
|
||||||
timeout: 2000,
|
timeout: 2000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.directive('click-outside', ClickOutside)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
checkToken().then(valid => {
|
checkToken().then(valid => {
|
||||||
|
|||||||
@@ -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 || '')
|
||||||
|
|||||||
@@ -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: '🇰🇷'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) // 积分值
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user