mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-04 11:00:47 +08:00
Merge branch 'main' of github.com:nagisa77/OpenIsle
This commit is contained in:
79
frontend/src/components/ActivityPopup.vue
Normal file
79
frontend/src/components/ActivityPopup.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<BasePopup :visible="visible" @close="close">
|
||||
<div class="activity-popup">
|
||||
<img v-if="icon" :src="icon" class="activity-popup-icon" alt="activity icon" />
|
||||
<div class="activity-popup-text">{{ text }}</div>
|
||||
<div class="activity-popup-actions">
|
||||
<div class="activity-popup-button" @click="gotoActivity">立即前往</div>
|
||||
<div class="activity-popup-close" @click="close">稍后再说</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BasePopup from './BasePopup.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ActivityPopup',
|
||||
components: { BasePopup },
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
icon: String,
|
||||
text: String
|
||||
},
|
||||
emits: ['close'],
|
||||
setup (props, { emit }) {
|
||||
const router = useRouter()
|
||||
const gotoActivity = () => {
|
||||
emit('close')
|
||||
router.push('/activities')
|
||||
}
|
||||
const close = () => emit('close')
|
||||
return { gotoActivity, close }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.activity-popup-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.activity-popup-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
.activity-popup-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-popup-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
.activity-popup-close {
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.activity-popup-close:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
71
frontend/src/components/ArticleCategory.vue
Normal file
71
frontend/src/components/ArticleCategory.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="article-category-container" v-if="category">
|
||||
<div class="article-info-item" @click="gotoCategory">
|
||||
<img
|
||||
v-if="category.smallIcon"
|
||||
class="article-info-item-img"
|
||||
:src="category.smallIcon"
|
||||
:alt="category.name"
|
||||
/>
|
||||
<div class="article-info-item-text">{{ category.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ArticleCategory',
|
||||
props: {
|
||||
category: { type: Object, default: null }
|
||||
},
|
||||
setup(props) {
|
||||
const router = useRouter()
|
||||
const gotoCategory = () => {
|
||||
if (!props.category) return
|
||||
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
||||
router.push({ path: '/', query: { category: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
return { gotoCategory }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.article-category-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.article-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
background-color: var(--article-info-background-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.article-info-item-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.article-info-item-img {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.article-info-item {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/ArticleTags.vue
Normal file
79
frontend/src/components/ArticleTags.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="article-tags-container">
|
||||
<div
|
||||
class="article-info-item"
|
||||
v-for="tag in tags"
|
||||
:key="tag.id || tag.name"
|
||||
@click="gotoTag(tag)"
|
||||
>
|
||||
<img
|
||||
v-if="tag.smallIcon"
|
||||
class="article-info-item-img"
|
||||
:src="tag.smallIcon"
|
||||
:alt="tag.name"
|
||||
/>
|
||||
<div class="article-info-item-text">{{ tag.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ArticleTags',
|
||||
props: {
|
||||
tags: { type: Array, default: () => [] }
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
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>
|
||||
|
||||
.article-tags-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.article-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
background-color: var(--article-info-background-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.article-info-item-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.article-info-item-img {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.article-info-item {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
82
frontend/src/components/BaseInput.vue
Normal file
82
frontend/src/components/BaseInput.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="base-input">
|
||||
<i v-if="icon" :class="['base-input-icon', icon]" />
|
||||
|
||||
<!-- 普通输入框 -->
|
||||
<input
|
||||
v-if="!textarea"
|
||||
class="base-input-text"
|
||||
:type="type"
|
||||
v-bind="$attrs"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
|
||||
<!-- 多行输入框 -->
|
||||
<textarea
|
||||
v-else
|
||||
class="base-input-text"
|
||||
v-bind="$attrs"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BaseInput',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
textarea: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
innerValue: {
|
||||
get() {
|
||||
return this.modelValue
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:modelValue', val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(100% - 40px);
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.base-input:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.base-input-icon {
|
||||
opacity: 0.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.base-input-text {
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
}
|
||||
</style>
|
||||
34
frontend/src/components/BasePlaceholder.vue
Normal file
34
frontend/src/components/BasePlaceholder.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="base-placeholder">
|
||||
<i :class="['base-placeholder-icon', icon]" />
|
||||
<div class="base-placeholder-text">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BasePlaceholder',
|
||||
props: {
|
||||
text: { type: String, default: '' },
|
||||
icon: { type: String, default: 'fas fa-inbox' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-placeholder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.base-placeholder-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
</style>
|
||||
58
frontend/src/components/BasePopup.vue
Normal file
58
frontend/src/components/BasePopup.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div v-if="visible" class="popup">
|
||||
<div class="popup-overlay" @click="onOverlayClick"></div>
|
||||
<div class="popup-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BasePopup',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
closeOnOverlay: { type: Boolean, default: true }
|
||||
},
|
||||
emits: ['close'],
|
||||
methods: {
|
||||
onOverlayClick () {
|
||||
if (this.closeOnOverlay) this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
}
|
||||
.popup-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
.popup-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
103
frontend/src/components/BaseTimeline.vue
Normal file
103
frontend/src/components/BaseTimeline.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item" v-for="(item, idx) in items" :key="idx">
|
||||
<div
|
||||
class="timeline-icon"
|
||||
:class="{ clickable: !!item.iconClick }"
|
||||
@click="item.iconClick && item.iconClick()"
|
||||
>
|
||||
<img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
|
||||
<i v-else-if="item.icon" :class="item.icon"></i>
|
||||
<span v-else-if="item.emoji" class="timeline-emoji">{{ item.emoji }}</span>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<slot name="item" :item="item">{{ item.content }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BaseTimeline',
|
||||
props: {
|
||||
items: { type: Array, default: () => [] }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-icon.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-emoji {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 15px;
|
||||
width: 2px;
|
||||
bottom: -20px;
|
||||
background: var(--text-color);
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.timeline-icon {
|
||||
margin-right: 2px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
35
frontend/src/components/CallbackPage.vue
Normal file
35
frontend/src/components/CallbackPage.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="callback-page">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
<div class="callback-page-text">Magic is happening...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { hatch } from 'ldrs'
|
||||
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'CallbackPage'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callback-page {
|
||||
background-color: var(--background-color);
|
||||
height: calc(100vh - var(--header-height));
|
||||
padding-top: var(--header-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.callback-page-text {
|
||||
margin-top: 25px;
|
||||
font-size: 16px;
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
86
frontend/src/components/CategorySelect.vue
Normal file
86
frontend/src/components/CategorySelect.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<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" />
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
</div>
|
||||
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import Dropdown from './Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'CategorySelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
val => {
|
||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||
}
|
||||
)
|
||||
|
||||
const fetchCategories = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return [{ id: '', name: '无分类' }, ...data]
|
||||
}
|
||||
|
||||
const isImageIcon = icon => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
return { fetchCategories, selected, isImageIcon, providedOptions }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.option-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-weight: bold;
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
175
frontend/src/components/CommentEditor.vue
Normal file
175
frontend/src/components/CommentEditor.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="comment-editor-container">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed, watch, onUnmounted } from 'vue'
|
||||
import { themeState } from '../utils/theme'
|
||||
import {
|
||||
createVditor,
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil
|
||||
} from '../utils/vditor'
|
||||
import LoginOverlay from './LoginOverlay.vue'
|
||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||
|
||||
export default {
|
||||
name: 'CommentEditor',
|
||||
emits: ['submit'],
|
||||
props: {
|
||||
editorId: {
|
||||
type: String,
|
||||
default: () => 'editor-' + Math.random().toString(36).slice(2)
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showLoginOverlay: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
components: { LoginOverlay },
|
||||
setup(props, { emit }) {
|
||||
const vditorInstance = ref(null)
|
||||
const text = ref('')
|
||||
const getEditorTheme = getEditorThemeUtil
|
||||
const getPreviewTheme = getPreviewThemeUtil
|
||||
const applyTheme = () => {
|
||||
if (vditorInstance.value) {
|
||||
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = computed(() => props.loading || props.disabled || !text.value.trim())
|
||||
|
||||
const submit = () => {
|
||||
if (!vditorInstance.value || isDisabled.value) return
|
||||
const value = vditorInstance.value.getValue()
|
||||
emit('submit', value)
|
||||
vditorInstance.value.setValue('')
|
||||
text.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
vditorInstance.value = createVditor(props.editorId, {
|
||||
placeholder: '说点什么...',
|
||||
preview: {
|
||||
actions: [],
|
||||
markdown: { toc: false }
|
||||
},
|
||||
input(value) {
|
||||
text.value = value
|
||||
},
|
||||
after() {
|
||||
if (props.loading || props.disabled) {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
})
|
||||
// applyTheme()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearVditorStorage()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
val => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.disabled) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
val => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.loading) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => themeState.mode,
|
||||
() => {
|
||||
applyTheme()
|
||||
}
|
||||
)
|
||||
|
||||
return { submit, isDisabled }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment-editor-container {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.comment-bottom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.comment-submit {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comment-submit.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.comment-submit.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.comment-submit:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comment-editor-container {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
293
frontend/src/components/CommentItem.vue
Normal file
293
frontend/src/components/CommentItem.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<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" />
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="info-content">
|
||||
<div class="common-info-content-header">
|
||||
<div class="info-content-header-left">
|
||||
<span class="user-name">{{ comment.userName }}</span>
|
||||
<span v-if="level >= 2">
|
||||
<i class="fas fa-reply reply-icon"></i>
|
||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||
</span>
|
||||
<div class="post-time">{{ comment.time }}</div>
|
||||
</div>
|
||||
<div class="info-content-header-right">
|
||||
<DropdownMenu v-if="commentMenuItems.length > 0" :items="commentMenuItems">
|
||||
<template #trigger>
|
||||
<i class="fas fa-ellipsis-vertical action-menu-icon"></i>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</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">
|
||||
<i class="far fa-comment"></i>
|
||||
</div>
|
||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||
<i class="fas fa-link"></i>
|
||||
</div>
|
||||
</ReactionsGroup>
|
||||
</div>
|
||||
<div class="comment-editor-wrapper">
|
||||
<CommentEditor v-if="showEditor" @submit="submitReply" :loading="isWaitingForReply" :disabled="!loggedIn"
|
||||
:show-login-overlay="!loggedIn" />
|
||||
</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>
|
||||
<i v-else class="fas fa-chevron-down reply-toggle-icon"></i>
|
||||
{{ replyCount }}条回复
|
||||
</div>
|
||||
<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" />
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<vue-easy-lightbox :visible="lightboxVisible" :imgs="lightboxImgs" :index="lightboxIndex"
|
||||
@hide="lightboxVisible = false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CommentEditor from './CommentEditor.vue'
|
||||
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
|
||||
import TimeManager from '../utils/time'
|
||||
import BaseTimeline from './BaseTimeline.vue'
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, authState } from '../utils/auth'
|
||||
import ReactionsGroup from './ReactionsGroup.vue'
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
import LoginOverlay from './LoginOverlay.vue'
|
||||
|
||||
const CommentItem = {
|
||||
name: 'CommentItem',
|
||||
emits: ['deleted'],
|
||||
props: {
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
defaultShowReplies: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
||||
watch(
|
||||
() => props.defaultShowReplies,
|
||||
(val) => {
|
||||
showReplies.value = props.level === 0 ? true : val
|
||||
}
|
||||
)
|
||||
const showEditor = ref(false)
|
||||
const isWaitingForReply = ref(false)
|
||||
const lightboxVisible = ref(false)
|
||||
const lightboxIndex = ref(0)
|
||||
const lightboxImgs = ref([])
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||
const toggleReplies = () => {
|
||||
showReplies.value = !showReplies.value
|
||||
}
|
||||
const toggleEditor = () => {
|
||||
showEditor.value = !showEditor.value
|
||||
}
|
||||
|
||||
// 合并所有子回复为一个扁平数组
|
||||
const flattenReplies = (list) => {
|
||||
let result = []
|
||||
for (const r of list) {
|
||||
result.push(r)
|
||||
if (r.reply && r.reply.length > 0) {
|
||||
result = result.concat(flattenReplies(r.reply))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const replyList = computed(() => {
|
||||
if (props.level < 1) {
|
||||
return props.comment.reply
|
||||
}
|
||||
|
||||
return flattenReplies(props.comment.reply || [])
|
||||
})
|
||||
|
||||
const isAuthor = computed(() => authState.username === props.comment.userName)
|
||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||
const commentMenuItems = computed(() =>
|
||||
(isAuthor.value || isAdmin.value) ? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] : []
|
||||
)
|
||||
const deleteComment = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('已删除')
|
||||
emit('deleted', props.comment.id)
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
const submitReply = async (text) => {
|
||||
if (!text.trim()) return
|
||||
isWaitingForReply.value = true
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
isWaitingForReply.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content: text })
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const replyList = props.comment.reply || (props.comment.reply = [])
|
||||
replyList.push({
|
||||
id: data.id,
|
||||
userName: data.author.username,
|
||||
time: TimeManager.format(data.createdAt),
|
||||
avatar: data.author.avatar,
|
||||
text: data.content,
|
||||
reactions: [],
|
||||
reply: (data.replies || []).map(r => ({
|
||||
id: r.id,
|
||||
userName: r.author.username,
|
||||
time: TimeManager.format(r.createdAt),
|
||||
avatar: r.author.avatar,
|
||||
text: r.content,
|
||||
reactions: r.reactions || [],
|
||||
reply: [],
|
||||
openReplies: false,
|
||||
src: r.author.avatar,
|
||||
iconClick: () => router.push(`/users/${r.author.id}`)
|
||||
})),
|
||||
openReplies: false,
|
||||
src: data.author.avatar,
|
||||
iconClick: () => router.push(`/users/${data.author.id}`)
|
||||
})
|
||||
showEditor.value = false
|
||||
toast.success('回复成功')
|
||||
} else {
|
||||
toast.error('回复失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('回复失败')
|
||||
} finally {
|
||||
isWaitingForReply.value = false
|
||||
}
|
||||
}
|
||||
const copyCommentLink = () => {
|
||||
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
toast.success('已复制')
|
||||
})
|
||||
}
|
||||
const handleContentClick = e => {
|
||||
handleMarkdownClick(e)
|
||||
if (e.target.tagName === 'IMG') {
|
||||
const container = e.target.parentNode
|
||||
const imgs = [...container.querySelectorAll('img')].map(i => i.src)
|
||||
lightboxImgs.value = imgs
|
||||
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||
lightboxVisible.value = true
|
||||
}
|
||||
}
|
||||
return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply, commentMenuItems, deleteComment, lightboxVisible, lightboxIndex, lightboxImgs, handleContentClick, loggedIn, replyCount, replyList }
|
||||
}
|
||||
}
|
||||
|
||||
CommentItem.components = { CommentItem, CommentEditor, BaseTimeline, ReactionsGroup, DropdownMenu, VueEasyLightbox, LoginOverlay }
|
||||
|
||||
export default CommentItem
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reply-toggle {
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.reply-list {}
|
||||
|
||||
.comment-reaction {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.comment-reaction:hover {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
animation: highlight 2s;
|
||||
}
|
||||
|
||||
.reply-toggle-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.common-info-content-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reply-icon {
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.reply-user-name {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
from {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.reply-icon {
|
||||
margin-right: 3px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
358
frontend/src/components/Dropdown.vue
Normal file
358
frontend/src/components/Dropdown.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="dropdown" ref="wrapper">
|
||||
<div class="dropdown-display" @click="toggle">
|
||||
<slot name="display" :selected="selectedLabels" :toggle="toggle" :search="search" :setSearch="setSearch">
|
||||
<template v-if="multiple">
|
||||
<span v-if="selectedLabels.length">
|
||||
<template v-for="(label, idx) in selectedLabels" :key="label.id">
|
||||
<div class="selected-label">
|
||||
<template v-if="label.icon">
|
||||
<img v-if="isImageIcon(label.icon)" :src="label.icon" class="option-icon" :alt="label.name" />
|
||||
<i v-else :class="['option-icon', label.icon]"></i>
|
||||
</template>
|
||||
<span>{{ label.name }}</span>
|
||||
</div>
|
||||
<span v-if="idx !== selectedLabels.length - 1">, </span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="selectedLabels.length">
|
||||
<div class="selected-label">
|
||||
<template v-if="selectedLabels[0].icon">
|
||||
<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>
|
||||
</template>
|
||||
<span>{{ selectedLabels[0].name }}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||
</template>
|
||||
<i class="fas fa-caret-down dropdown-caret"></i>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)" :class="['dropdown-menu', menuClass]">
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<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>
|
||||
</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) }]">
|
||||
<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" />
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="open && isMobile" class="dropdown-mobile-page">
|
||||
<div class="dropdown-mobile-header">
|
||||
<i class="fas fa-arrow-left" @click="close"></i>
|
||||
<span class="mobile-title">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div class="dropdown-mobile-menu">
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<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>
|
||||
</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) }]">
|
||||
<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" />
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { hatch } from 'ldrs'
|
||||
import { isMobile } from '../utils/screen'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'BaseDropdown',
|
||||
props: {
|
||||
modelValue: { type: [Array, String, Number], 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: '' },
|
||||
showSearch: { type: Boolean, default: true },
|
||||
initialOptions: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['update:modelValue', 'update:search', 'close'],
|
||||
setup(props, { emit, expose }) {
|
||||
const open = ref(false)
|
||||
const search = ref('')
|
||||
const setSearch = (val) => {
|
||||
search.value = val
|
||||
}
|
||||
const options = ref(Array.isArray(props.initialOptions) ? [...props.initialOptions] : [])
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const wrapper = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
if (!open.value) emit('close')
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
open.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const select = id => {
|
||||
if (props.multiple) {
|
||||
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const idx = arr.indexOf(id)
|
||||
if (idx > -1) {
|
||||
arr.splice(idx, 1)
|
||||
} else {
|
||||
arr.push(id)
|
||||
}
|
||||
emit('update:modelValue', arr)
|
||||
} else {
|
||||
emit('update:modelValue', id)
|
||||
close()
|
||||
}
|
||||
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()))
|
||||
})
|
||||
|
||||
const clickOutside = e => {
|
||||
if (isMobile) return
|
||||
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const loadOptions = async (kw = '') => {
|
||||
if (!props.remote && loaded.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await props.fetchOptions(props.remote ? kw : undefined)
|
||||
options.value = Array.isArray(res) ? res : []
|
||||
if (!props.remote) loaded.value = true
|
||||
} catch {
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialOptions,
|
||||
val => {
|
||||
if (Array.isArray(val)) {
|
||||
options.value = [...val]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(open, async val => {
|
||||
if (val) {
|
||||
if (props.remote) {
|
||||
await loadOptions(search.value)
|
||||
} else if (!loaded.value) {
|
||||
await loadOptions()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(search, async val => {
|
||||
emit('update:search', val)
|
||||
if (props.remote && open.value) {
|
||||
await loadOptions(val)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', clickOutside)
|
||||
if (!props.remote) {
|
||||
loadOptions()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', clickOutside)
|
||||
})
|
||||
|
||||
const selectedLabels = computed(() => {
|
||||
if (props.multiple) {
|
||||
return options.value.filter(o => (props.modelValue || []).includes(o.id))
|
||||
}
|
||||
const match = options.value.find(o => o.id === props.modelValue)
|
||||
return match ? [match] : []
|
||||
})
|
||||
|
||||
const isSelected = (id) => {
|
||||
return selectedLabels.value.some(label => label.id === id)
|
||||
}
|
||||
|
||||
const isImageIcon = icon => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
expose({ toggle, close })
|
||||
|
||||
return {
|
||||
open,
|
||||
toggle,
|
||||
close,
|
||||
select,
|
||||
search,
|
||||
filteredOptions,
|
||||
wrapper,
|
||||
selectedLabels,
|
||||
isSelected,
|
||||
loading,
|
||||
isImageIcon,
|
||||
setSearch,
|
||||
isMobile
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-display {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
z-index: 10000;
|
||||
max-height: 200px;
|
||||
min-width: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.dropdown-search input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
margin-left: 5px;
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-option.selected {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropdown-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.dropdown-mobile-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--menu-background-color);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dropdown-mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.dropdown-mobile-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
89
frontend/src/components/DropdownMenu.vue
Normal file
89
frontend/src/components/DropdownMenu.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="dropdown-wrapper" ref="wrapper">
|
||||
<div class="dropdown-trigger" @click="toggle">
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
<div v-if="visible" class="dropdown-menu-container">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="dropdown-item"
|
||||
:style="{ color: item.color || 'inherit' }"
|
||||
@click="handle(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
export default {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
items: { type: Array, default: () => [] }
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
const visible = ref(false)
|
||||
const wrapper = ref(null)
|
||||
const toggle = () => {
|
||||
visible.value = !visible.value
|
||||
}
|
||||
const close = () => {
|
||||
visible.value = false
|
||||
}
|
||||
const handle = item => {
|
||||
close()
|
||||
if (item && typeof item.onClick === 'function') {
|
||||
item.onClick()
|
||||
}
|
||||
}
|
||||
const clickOutside = e => {
|
||||
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', clickOutside)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', clickOutside)
|
||||
})
|
||||
expose({ close })
|
||||
return { visible, toggle, wrapper, handle }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.dropdown-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: var(--menu-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
min-width: 100px;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
</style>
|
||||
51
frontend/src/components/GlobalPopups.vue
Normal file
51
frontend/src/components/GlobalPopups.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<ActivityPopup
|
||||
:visible="showMilkTeaPopup"
|
||||
:icon="milkTeaIcon"
|
||||
text="建站送奶茶活动火热进行中,快来参与吧!"
|
||||
@close="closeMilkTeaPopup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ActivityPopup from './ActivityPopup.vue'
|
||||
import { API_BASE_URL } from '../main'
|
||||
|
||||
export default {
|
||||
name: 'GlobalPopups',
|
||||
components: { ActivityPopup },
|
||||
data () {
|
||||
return {
|
||||
showMilkTeaPopup: false,
|
||||
milkTeaIcon: ''
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
await this.checkMilkTeaActivity()
|
||||
},
|
||||
methods: {
|
||||
async checkMilkTeaActivity () {
|
||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
if (res.ok) {
|
||||
const list = await res.json()
|
||||
const a = list.find(i => i.type === 'MILK_TEA' && !i.ended)
|
||||
if (a) {
|
||||
this.milkTeaIcon = a.icon
|
||||
this.showMilkTeaPopup = true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore network errors
|
||||
}
|
||||
},
|
||||
closeMilkTeaPopup () {
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
this.showMilkTeaPopup = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
312
frontend/src/components/HeaderComponent.vue
Normal file
312
frontend/src/components/HeaderComponent.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-content-left">
|
||||
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||
<button class="menu-btn" @click="$emit('toggle-menu')">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<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">
|
||||
<div class="logo-text">OpenIsle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLogin" class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar">
|
||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div v-else class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
</div>
|
||||
|
||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { authState, clearToken, loadCurrentUser } from '../utils/auth'
|
||||
import { watch, nextTick } from 'vue'
|
||||
import { fetchUnreadCount, notificationState } from '../utils/notification'
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
import SearchDropdown from './SearchDropdown.vue'
|
||||
import { isMobile } from '../utils/screen'
|
||||
|
||||
export default {
|
||||
name: 'HeaderComponent',
|
||||
components: { DropdownMenu, SearchDropdown },
|
||||
props: {
|
||||
showMenuBtn: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
avatar: '',
|
||||
showSearch: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isLogin() {
|
||||
return authState.loggedIn
|
||||
},
|
||||
isMobile() {
|
||||
return isMobile.value
|
||||
},
|
||||
headerMenuItems() {
|
||||
return [
|
||||
{ text: '设置', onClick: this.goToSettings },
|
||||
{ text: '个人主页', onClick: this.goToProfile },
|
||||
{ text: '退出', onClick: this.goToLogout }
|
||||
]
|
||||
},
|
||||
unreadCount() {
|
||||
return notificationState.unreadCount
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const updateAvatar = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await loadCurrentUser()
|
||||
if (user && user.avatar) {
|
||||
this.avatar = user.avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
const updateUnread = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
} else {
|
||||
notificationState.unreadCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
methods: {
|
||||
goToHome() {
|
||||
this.$router.push('/').then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
},
|
||||
search() {
|
||||
this.showSearch = true
|
||||
nextTick(() => {
|
||||
this.$refs.searchDropdown.toggle()
|
||||
})
|
||||
},
|
||||
closeSearch() {
|
||||
nextTick(() => {
|
||||
this.showSearch = false
|
||||
})
|
||||
},
|
||||
goToLogin() {
|
||||
this.$router.push('/login')
|
||||
},
|
||||
goToSettings() {
|
||||
this.$router.push('/settings')
|
||||
},
|
||||
async goToProfile() {
|
||||
if (!authState.loggedIn) {
|
||||
this.$router.push('/login')
|
||||
return
|
||||
}
|
||||
let id = authState.username || authState.userId
|
||||
if (!id) {
|
||||
const user = await loadCurrentUser()
|
||||
if (user) {
|
||||
id = user.username || user.id
|
||||
}
|
||||
}
|
||||
if (id) {
|
||||
this.$router.push(`/users/${id}`).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
},
|
||||
goToSignup() {
|
||||
this.$router.push('/signup')
|
||||
},
|
||||
goToLogout() {
|
||||
clearToken()
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--header-text-color);
|
||||
border-bottom: 1px solid var(--header-border-color);
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: var(--page-max-width);
|
||||
}
|
||||
|
||||
.header-content-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.menu-btn-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.menu-unread-dot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.menu-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.header-content-item-main {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-content-item-main:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.header-content-item-secondary {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: lightgray;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.header-content {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content-item-secondary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
59
frontend/src/components/LevelProgress.vue
Normal file
59
frontend/src/components/LevelProgress.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="level-progress">
|
||||
<div class="level-progress-current">当前Lv.{{ currentLevel }}</div>
|
||||
<ProgressBar :value="value" :max="max" />
|
||||
<div class="level-progress-info">
|
||||
<div class="level-progress-exp">{{ exp }} / {{ nextExp }}</div>
|
||||
<div class="level-progress-target">🎉目标 Lv.{{ currentLevel + 1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
import { prevLevelExp } from '../utils/level'
|
||||
export default {
|
||||
name: 'LevelProgress',
|
||||
components: { ProgressBar },
|
||||
props: {
|
||||
exp: { type: Number, default: 0 },
|
||||
currentLevel: { type: Number, default: 0 },
|
||||
nextExp: { type: Number, default: 0 }
|
||||
},
|
||||
computed: {
|
||||
max () {
|
||||
return this.nextExp - prevLevelExp(this.currentLevel)
|
||||
},
|
||||
value () {
|
||||
return this.exp - prevLevelExp(this.currentLevel)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.level-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.level-progress-current {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.level-progress-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-progress-exp,
|
||||
.level-progress-target {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
86
frontend/src/components/LoginOverlay.vue
Normal file
86
frontend/src/components/LoginOverlay.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="login-overlay">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'LoginOverlay',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const goLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
return { goLogin }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-overlay-blur {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-overlay-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
padding: 24px 32px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
row-gap: 20px;
|
||||
}
|
||||
|
||||
.login-overlay-icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.login-overlay-button {
|
||||
padding: 4px 12px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-overlay-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
</style>
|
||||
444
frontend/src/components/MenuComponent.vue
Normal file
444
frontend/src/components/MenuComponent.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<nav v-if="visible" class="menu">
|
||||
<div class="menu-item-container">
|
||||
<router-link
|
||||
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>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<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 categories" :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" />
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" 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="t in tags" :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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { themeState, cycleTheme, ThemeMode } from '../utils/theme'
|
||||
import { authState } from '../utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '../utils/notification'
|
||||
import { watch } from 'vue'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'MenuComponent',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
categories: [],
|
||||
tags: [],
|
||||
categoryOpen: true,
|
||||
tagOpen: true,
|
||||
isLoadingCategory: false,
|
||||
isLoadingTag: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
iconClass() {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
}
|
||||
},
|
||||
unreadCount() {
|
||||
return notificationState.unreadCount
|
||||
},
|
||||
showUnreadCount() {
|
||||
return this.unreadCount > 99 ? '99+' : this.unreadCount
|
||||
},
|
||||
shouldShowStats() {
|
||||
return authState.role === 'ADMIN'
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const updateCount = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
} else {
|
||||
notificationState.unreadCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => authState.loggedIn, async () => {
|
||||
await updateCount()
|
||||
})
|
||||
|
||||
const CAT_CACHE_KEY = 'menu-categories'
|
||||
const TAG_CACHE_KEY = 'menu-tags'
|
||||
|
||||
const cachedCategories = localStorage.getItem(CAT_CACHE_KEY)
|
||||
if (cachedCategories) {
|
||||
try {
|
||||
this.categories = JSON.parse(cachedCategories)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const cachedTags = localStorage.getItem(TAG_CACHE_KEY)
|
||||
if (cachedTags) {
|
||||
try {
|
||||
this.tags = JSON.parse(cachedTags)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
this.isLoadingCategory = !cachedCategories
|
||||
this.isLoadingTag = !cachedTags
|
||||
|
||||
const fetchCategories = () => {
|
||||
fetch(`${API_BASE_URL}/api/categories`).then(res => {
|
||||
if (res.ok) {
|
||||
res.json().then(data => {
|
||||
this.categories = data.slice(0, 10)
|
||||
localStorage.setItem(CAT_CACHE_KEY, JSON.stringify(this.categories))
|
||||
})
|
||||
}
|
||||
this.isLoadingCategory = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchTags = () => {
|
||||
fetch(`${API_BASE_URL}/api/tags?limit=10`).then(res => {
|
||||
if (res.ok) {
|
||||
res.json().then(data => {
|
||||
this.tags = data
|
||||
localStorage.setItem(TAG_CACHE_KEY, JSON.stringify(this.tags))
|
||||
})
|
||||
}
|
||||
this.isLoadingTag = false
|
||||
})
|
||||
}
|
||||
|
||||
if (cachedCategories) {
|
||||
setTimeout(fetchCategories, 1500)
|
||||
} else {
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
if (cachedTags) {
|
||||
setTimeout(fetchTags, 1500)
|
||||
} else {
|
||||
fetchTags()
|
||||
}
|
||||
|
||||
await updateCount()
|
||||
},
|
||||
methods: {
|
||||
cycleTheme,
|
||||
handleHomeClick() {
|
||||
this.$router.push('/').then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
},
|
||||
handleItemClick() {
|
||||
if (window.innerWidth <= 768) this.$emit('item-click')
|
||||
},
|
||||
isImageIcon(icon) {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
},
|
||||
gotoCategory(c) {
|
||||
const value = encodeURIComponent(c.id ?? c.name)
|
||||
this.$router
|
||||
.push({ path: '/', query: { category: value } })
|
||||
.then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
this.handleItemClick()
|
||||
},
|
||||
gotoTag(t) {
|
||||
const value = encodeURIComponent(t.id ?? t.name)
|
||||
this.$router
|
||||
.push({ path: '/', query: { tags: value } })
|
||||
.then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
this.handleItemClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
width: 200px;
|
||||
background-color: var(--menu-background-color);
|
||||
height: calc(100vh - 20px - var(--header-height));
|
||||
border-right: 1px solid var(--menu-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
padding-top: calc(var(--header-height) + 10px);
|
||||
}
|
||||
|
||||
.menu-item-container {
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 4px 10px;
|
||||
text-decoration: none;
|
||||
color: var(--menu-text-color);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-item.selected {
|
||||
font-weight: bold;
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.menu-item-text {
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
color: var(--menu-text-color);
|
||||
}
|
||||
|
||||
.unread-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(255, 102, 102);
|
||||
margin-left: 15px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unread {
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: fixed;
|
||||
height: 30px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.menu-footer-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: bold;
|
||||
opacity: 0.5;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-items {
|
||||
color: var(--menu-text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section-item {
|
||||
padding: 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.section-item-text-count {
|
||||
font-size: 12px;
|
||||
color: var(--menu-text-color);
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.section-item-text {
|
||||
color: var(--menu-text-color);
|
||||
}
|
||||
|
||||
.section-item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
left: 10px;
|
||||
border-radius: 20px;
|
||||
border-right: none;
|
||||
height: 400px;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
padding-top: 10px;
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease,
|
||||
width 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-to,
|
||||
.slide-leave-from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
247
frontend/src/components/MilkTeaActivityComponent.vue
Normal file
247
frontend/src/components/MilkTeaActivityComponent.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="milk-tea-activity">
|
||||
<div class="milk-tea-description">
|
||||
<div class="milk-tea-description-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span class="milk-tea-description-title-text">升级规则说明</span>
|
||||
</div>
|
||||
<div class="milk-tea-description-content">
|
||||
<p>回复帖子每次10exp,最多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 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" />
|
||||
</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>
|
||||
<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>
|
||||
</BasePopup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
import LevelProgress from './LevelProgress.vue'
|
||||
import BaseInput from './BaseInput.vue'
|
||||
import BasePopup from './BasePopup.vue'
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, fetchCurrentUser } from '../utils/auth'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'MilkTeaActivityComponent',
|
||||
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
||||
data () {
|
||||
return {
|
||||
info: { redeemCount: 0, ended: false },
|
||||
user: null,
|
||||
dialogVisible: false,
|
||||
contact: '',
|
||||
loading: false,
|
||||
isLoadingUser: true,
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
await this.loadInfo()
|
||||
this.isLoadingUser = true
|
||||
this.user = await fetchCurrentUser()
|
||||
this.isLoadingUser = false
|
||||
},
|
||||
methods: {
|
||||
async loadInfo () {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||
if (res.ok) {
|
||||
this.info = await res.json()
|
||||
}
|
||||
},
|
||||
openDialog () {
|
||||
this.dialogVisible = true
|
||||
},
|
||||
closeDialog () {
|
||||
this.dialogVisible = false
|
||||
},
|
||||
async submitRedeem () {
|
||||
if (!this.contact) return
|
||||
this.loading = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ contact: this.contact })
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.message === 'updated') {
|
||||
toast.success('您已提交过兑换,本次更新兑换信息')
|
||||
} else {
|
||||
toast.success('兑换成功!')
|
||||
}
|
||||
this.dialogVisible = false
|
||||
await this.loadInfo()
|
||||
} else {
|
||||
toast.error('兑换失败')
|
||||
}
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.milk-tea-description-title-text {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.milk-tea-description-content {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.milk-tea-activity {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.redeem-button {
|
||||
margin-top: 20px;
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.redeem-button.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.redeem-button.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
|
||||
.milk-tea-status-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.milk-tea-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.redeem-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeem-submit-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.redeem-submit-button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.redeem-submit-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
.redeem-submit-button:disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
.redeem-cancel-button {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.redeem-cancel-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.user-level-text {
|
||||
opacity: 0.8;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.milk-tea-status-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
63
frontend/src/components/NotificationContainer.vue
Normal file
63
frontend/src/components/NotificationContainer.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="notif-content-container">
|
||||
<div class="notif-content-container-item">
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="actions">
|
||||
<div v-if="!item.read" class="mark-read-button" @click="markRead(item.id)">
|
||||
{{ isMobile ? 'OK' : '标记为已读' }}
|
||||
</div>
|
||||
<div v-else class="has-read-button">已读</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { isMobile } from '../utils/screen'
|
||||
export default {
|
||||
name: 'NotificationContainer',
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
markRead: { type: Function, required: true }
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
isMobile
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notif-content-container {
|
||||
color: rgb(140, 140, 140);
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mark-read-button {
|
||||
color: var(--primary-color);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.mark-read-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.has-read-button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.has-read-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
138
frontend/src/components/PostEditor.vue
Normal file
138
frontend/src/components/PostEditor.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="post-editor-container">
|
||||
<div :id="editorId" ref="vditorElement"></div>
|
||||
<div v-if="loading" class="editor-loading-overlay">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { themeState } from '../utils/theme'
|
||||
import {
|
||||
createVditor,
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil
|
||||
} from '../utils/vditor'
|
||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
|
||||
|
||||
export default {
|
||||
name: 'PostEditor',
|
||||
emits: ['update:modelValue'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
editorId: {
|
||||
type: String,
|
||||
default: () => 'post-editor-' + Math.random().toString(36).slice(2)
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const vditorInstance = ref(null)
|
||||
const getEditorTheme = getEditorThemeUtil
|
||||
const getPreviewTheme = getPreviewThemeUtil
|
||||
const applyTheme = () => {
|
||||
if (vditorInstance.value) {
|
||||
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
val => {
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
val => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.loading) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (vditorInstance.value && vditorInstance.value.getValue() !== val) {
|
||||
vditorInstance.value.setValue(val)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => themeState.mode,
|
||||
() => {
|
||||
applyTheme()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
vditorInstance.value = createVditor(props.editorId, {
|
||||
placeholder: '请输入正文...',
|
||||
input(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
after() {
|
||||
vditorInstance.value.setValue(props.modelValue)
|
||||
if (props.loading || props.disabled) {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
})
|
||||
// applyTheme()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearVditorStorage()
|
||||
})
|
||||
|
||||
return {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-editor-container {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--menu-selected-background-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: all;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
37
frontend/src/components/ProgressBar.vue
Normal file
37
frontend/src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-inner" :style="{ width: `${percent}%` }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ProgressBar',
|
||||
props: {
|
||||
value: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 100 }
|
||||
},
|
||||
computed: {
|
||||
percent () {
|
||||
if (this.max <= 0) return 0
|
||||
const p = (this.value / this.max) * 100
|
||||
return Math.max(0, Math.min(100, p))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar {
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
background-color: var(--normal-background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-inner {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
333
frontend/src/components/ReactionsGroup.vue
Normal file
333
frontend/src/components/ReactionsGroup.vue
Normal file
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="reactions-container">
|
||||
<div class="reactions-viewer">
|
||||
<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">{{ iconMap[r.type] }}</div>
|
||||
<div class="reactions-count">{{ totalCount }}</div>
|
||||
</template>
|
||||
<div v-else class="reactions-viewer-item placeholder">
|
||||
<i class="far fa-smile"></i>
|
||||
<span class="reactions-viewer-item-placeholder-text">点击以表态</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="make-reaction-container">
|
||||
<div class="make-reaction-item like-reaction" @click="toggleReaction('LIKE')">
|
||||
<i v-if="!userReacted('LIKE')" class="far fa-heart"></i>
|
||||
<i v-else class="fas fa-heart"></i>
|
||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
||||
</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) }">
|
||||
{{ iconMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, authState } from '../utils/auth'
|
||||
|
||||
let cachedTypes = null
|
||||
const fetchTypes = async () => {
|
||||
if (cachedTypes) return cachedTypes
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' }
|
||||
})
|
||||
if (res.ok) {
|
||||
cachedTypes = await res.json()
|
||||
} else {
|
||||
cachedTypes = []
|
||||
}
|
||||
} catch {
|
||||
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 {
|
||||
name: 'ReactionsGroup',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
contentType: { type: String, required: true },
|
||||
contentId: { type: [Number, String], required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const reactions = ref(props.modelValue)
|
||||
watch(() => props.modelValue, v => reactions.value = v)
|
||||
|
||||
const reactionTypes = ref([])
|
||||
onMounted(async () => {
|
||||
reactionTypes.value = await fetchTypes()
|
||||
})
|
||||
|
||||
const counts = computed(() => {
|
||||
const c = {}
|
||||
for (const r of reactions.value) {
|
||||
c[r.type] = (c[r.type] || 0) + 1
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
||||
|
||||
const userReacted = type => reactions.value.some(r => r.type === type && r.user === authState.username)
|
||||
|
||||
const displayedReactions = computed(() => {
|
||||
return Object.entries(counts.value)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([type]) => ({ type }))
|
||||
})
|
||||
|
||||
const panelTypes = computed(() => reactionTypes.value.filter(t => t !== 'LIKE'))
|
||||
|
||||
const panelVisible = ref(false)
|
||||
let hideTimer = null
|
||||
const openPanel = () => {
|
||||
clearTimeout(hideTimer)
|
||||
panelVisible.value = true
|
||||
}
|
||||
const scheduleHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => { panelVisible.value = false }, 500)
|
||||
}
|
||||
const cancelHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
const toggleReaction = async (type) => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const url = props.contentType === 'post'
|
||||
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
||||
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||
|
||||
// optimistic update
|
||||
const existingIdx = reactions.value.findIndex(r => r.type === type && r.user === authState.username)
|
||||
let tempReaction = null
|
||||
let removedReaction = null
|
||||
if (existingIdx > -1) {
|
||||
removedReaction = reactions.value.splice(existingIdx, 1)[0]
|
||||
} else {
|
||||
tempReaction = { type, user: authState.username }
|
||||
reactions.value.push(tempReaction)
|
||||
}
|
||||
emit('update:modelValue', reactions.value)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ type })
|
||||
})
|
||||
if (res.ok) {
|
||||
if (res.status === 204) {
|
||||
// removal already reflected
|
||||
} else {
|
||||
const data = await res.json()
|
||||
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
|
||||
if (idx > -1) {
|
||||
reactions.value.splice(idx, 1, data)
|
||||
} else if (removedReaction) {
|
||||
// server added back reaction even though we removed? restore data
|
||||
reactions.value.push(data)
|
||||
}
|
||||
if (data.reward && data.reward > 0) {
|
||||
toast.success(`获得 ${data.reward} 经验值`)
|
||||
}
|
||||
}
|
||||
emit('update:modelValue', reactions.value)
|
||||
} else {
|
||||
// revert optimistic update on failure
|
||||
if (tempReaction) {
|
||||
const idx = reactions.value.indexOf(tempReaction)
|
||||
if (idx > -1) reactions.value.splice(idx, 1)
|
||||
} else if (removedReaction) {
|
||||
reactions.value.push(removedReaction)
|
||||
}
|
||||
emit('update:modelValue', reactions.value)
|
||||
toast.error('操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
if (tempReaction) {
|
||||
const idx = reactions.value.indexOf(tempReaction)
|
||||
if (idx > -1) reactions.value.splice(idx, 1)
|
||||
} else if (removedReaction) {
|
||||
reactions.value.push(removedReaction)
|
||||
}
|
||||
emit('update:modelValue', reactions.value)
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
iconMap,
|
||||
counts,
|
||||
totalCount,
|
||||
likeCount,
|
||||
displayedReactions,
|
||||
panelTypes,
|
||||
panelVisible,
|
||||
openPanel,
|
||||
scheduleHide,
|
||||
cancelHide,
|
||||
toggleReaction,
|
||||
userReacted
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.reactions-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reactions-viewer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reactions-viewer-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #a2a2a2;
|
||||
}
|
||||
|
||||
.reactions-viewer-item {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.reactions-viewer-item.placeholder {
|
||||
opacity: 0.5;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reactions-viewer-item-placeholder-text {
|
||||
font-size: 14px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.make-reaction-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.make-reaction-item {
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
opacity: 0.5;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.like-reaction {
|
||||
color: #ff0000;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.make-reaction-item:hover {
|
||||
background-color: #ffe2e2;
|
||||
}
|
||||
|
||||
.reactions-count {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.reactions-panel {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: -20px;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
max-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
z-index: 10;
|
||||
gap: 2px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.reaction-option {
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reaction-option.selected {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.make-reaction-item {
|
||||
font-size: 16px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
185
frontend/src/components/SearchDropdown.vue
Normal file
185
frontend/src/components/SearchDropdown.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<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">
|
||||
<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)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="search-option-item">
|
||||
<i :class="['result-icon', iconMap[option.type] || 'fas fa-question']"></i>
|
||||
<div class="result-body">
|
||||
<div class="result-main" v-html="highlight(option.text)"></div>
|
||||
<div v-if="option.subText" class="result-sub" v-html="highlight(option.subText)"></div>
|
||||
<div v-if="option.extra" class="result-extra" v-html="highlight(option.extra)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch } from 'vue'
|
||||
import { isMobile } from '../utils/screen'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Dropdown from './Dropdown.vue'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { stripMarkdown } from '../utils/markdown'
|
||||
|
||||
export default {
|
||||
name: 'SearchDropdown',
|
||||
components: { Dropdown },
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
dropdown.value.toggle()
|
||||
}
|
||||
|
||||
const onClose = () => emit('close')
|
||||
|
||||
const fetchResults = async (kw) => {
|
||||
if (!kw) return []
|
||||
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
results.value = data.map(r => ({
|
||||
id: r.id,
|
||||
text: r.text,
|
||||
type: r.type,
|
||||
subText: r.subText,
|
||||
extra: r.extra,
|
||||
postId: r.postId
|
||||
}))
|
||||
return results.value
|
||||
}
|
||||
|
||||
const highlight = (text) => {
|
||||
text = stripMarkdown(text)
|
||||
if (!keyword.value) return text
|
||||
const reg = new RegExp(keyword.value, 'gi')
|
||||
const res = text.replace(reg, m => `<span class="highlight">${m}</span>`)
|
||||
return res;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
user: 'fas fa-user',
|
||||
post: 'fas fa-file-alt',
|
||||
comment: 'fas fa-comment',
|
||||
category: 'fas fa-folder',
|
||||
tag: 'fas fa-hashtag'
|
||||
}
|
||||
|
||||
watch(selected, val => {
|
||||
if (!val) return
|
||||
const opt = results.value.find(r => r.id === val)
|
||||
if (!opt) return
|
||||
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||
router.push(`/posts/${opt.id}`)
|
||||
} else if (opt.type === 'user') {
|
||||
router.push(`/users/${opt.id}`)
|
||||
} else if (opt.type === 'comment') {
|
||||
if (opt.postId) {
|
||||
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
|
||||
}
|
||||
} else if (opt.type === 'category') {
|
||||
router.push({ path: '/', query: { category: opt.id } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
} else if (opt.type === 'tag') {
|
||||
router.push({ path: '/', query: { tags: opt.id } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
return { keyword, selected, fetchResults, highlight, iconMap, isMobile, dropdown, onClose, toggle }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-dropdown {
|
||||
margin-top: 20px;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.search-mobile-trigger {
|
||||
padding: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-menu {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-option-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
:deep(.highlight) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.result-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-main {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.result-sub,
|
||||
.result-extra {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
149
frontend/src/components/TagSelect.vue
Normal file
149
frontend/src/components/TagSelect.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<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" />
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
</div>
|
||||
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import Dropdown from './Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'TagSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
creatable: { type: Boolean, default: false },
|
||||
options: { type: Array, default: () => [] }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const localTags = ref([])
|
||||
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
val => {
|
||||
providedTags.value = Array.isArray(val) ? [...val] : []
|
||||
}
|
||||
)
|
||||
|
||||
const mergedOptions = computed(() => {
|
||||
const arr = [...providedTags.value, ...localTags.value]
|
||||
return arr.filter((v, i, a) => a.findIndex(t => t.id === v.id) === i)
|
||||
})
|
||||
|
||||
const isImageIcon = icon => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
const buildTagsUrl = (kw = '') => {
|
||||
const base = API_BASE_URL || window.location.origin; // 若为空自动退回到本域名
|
||||
const url = new URL('/api/tags', base);
|
||||
|
||||
if (kw) url.searchParams.set('keyword', kw);
|
||||
url.searchParams.set('limit', '10');
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const fetchTags = async (kw = '') => {
|
||||
const defaultOption = { id: 0, name: '无标签' };
|
||||
|
||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||
const url = buildTagsUrl(kw);
|
||||
|
||||
// 2) 拉数据
|
||||
let data = [];
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) data = await res.json();
|
||||
} catch {
|
||||
toast.error('获取标签失败');
|
||||
}
|
||||
|
||||
// 3) 合并、去重、可创建
|
||||
let options = [...data, ...localTags.value];
|
||||
|
||||
if (props.creatable && kw &&
|
||||
!options.some(t => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` });
|
||||
}
|
||||
|
||||
options = Array.from(new Map(options.map(t => [t.id, t])).values());
|
||||
|
||||
// 4) 最终结果
|
||||
return [defaultOption, ...options];
|
||||
};
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => {
|
||||
if (Array.isArray(v)) {
|
||||
if (v.includes(0)) {
|
||||
emit('update:modelValue', [])
|
||||
return
|
||||
}
|
||||
if (v.length > 2) {
|
||||
toast.error('最多选择两个标签')
|
||||
return
|
||||
}
|
||||
v = v.map(id => {
|
||||
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
||||
const name = id.slice(11)
|
||||
const newId = `__new__:${name}`
|
||||
if (!localTags.value.find(t => t.id === newId)) {
|
||||
localTags.value.push({ id: newId, name })
|
||||
}
|
||||
return newId
|
||||
}
|
||||
return id
|
||||
})
|
||||
}
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
})
|
||||
|
||||
return { fetchTags, selected, isImageIcon, mergedOptions }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.option-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-weight: bold;
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
65
frontend/src/components/UserList.vue
Normal file
65
frontend/src/components/UserList.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="fas fa-inbox" />
|
||||
<div v-for="u in users" :key="u.id" class="user-item" @click="handleUserClick(u)">
|
||||
<img :src="u.avatar" alt="avatar" class="user-avatar" />
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ u.username }}</div>
|
||||
<div v-if="u.introduction" class="user-intro">{{ u.introduction }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BasePlaceholder from './BasePlaceholder.vue'
|
||||
|
||||
export default {
|
||||
name: 'UserList',
|
||||
components: { BasePlaceholder },
|
||||
props: {
|
||||
users: { type: Array, default: () => [] }
|
||||
},
|
||||
methods: {
|
||||
handleUserClick(user) {
|
||||
this.$router.push(`/users/${user.id}`).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.user-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.user-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.user-intro {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user