mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-21 06:20:59 +08:00
Merge pull request #427 from nagisa77/codex/assist-migration-of-other-pages-to-nuxt
feat: migrate legacy Vue pages to Nuxt
This commit is contained in:
142
frontend_nuxt/components/AvatarCropper.vue
Normal file
142
frontend_nuxt/components/AvatarCropper.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div v-if="show" class="cropper-modal">
|
||||
<div class="cropper-body">
|
||||
<div class="cropper-wrapper">
|
||||
<img ref="image" :src="src" alt="to crop" />
|
||||
</div>
|
||||
<div class="cropper-actions">
|
||||
<button class="cropper-btn" @click="$emit('close')">取消</button>
|
||||
<button class="cropper-btn primary" @click="onConfirm">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
|
||||
export default {
|
||||
name: 'AvatarCropper',
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close', 'crop'],
|
||||
data() {
|
||||
return { cropper: null }
|
||||
},
|
||||
watch: {
|
||||
show(val) {
|
||||
if (val) {
|
||||
this.$nextTick(() => this.init())
|
||||
} else {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.show) {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
const image = this.$refs.image
|
||||
this.cropper = new Cropper(image, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true
|
||||
})
|
||||
},
|
||||
destroy() {
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy()
|
||||
this.cropper = null
|
||||
}
|
||||
},
|
||||
onConfirm() {
|
||||
if (!this.cropper) return
|
||||
this.cropper.getCroppedCanvas({ width: 256, height: 256 }).toBlob(blob => {
|
||||
const file = new File([blob], 'avatar.png', { type: 'image/png' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.$emit('crop', { file, url })
|
||||
this.$emit('close')
|
||||
this.destroy()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cropper-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
opacity: 1.0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.cropper-body {
|
||||
background: var(--background-color);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cropper-wrapper {
|
||||
width: 80vw;
|
||||
height: 80vw;
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.cropper-wrapper img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cropper-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cropper-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--primary-color);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cropper-btn.primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.cropper-wrapper {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
82
frontend_nuxt/components/BaseInput.vue
Normal file
82
frontend_nuxt/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_nuxt/components/BasePlaceholder.vue
Normal file
34
frontend_nuxt/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>
|
||||
103
frontend_nuxt/components/BaseTimeline.vue
Normal file
103
frontend_nuxt/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>
|
||||
34
frontend_nuxt/components/CallbackPage.vue
Normal file
34
frontend_nuxt/components/CallbackPage.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<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);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.callback-page-text {
|
||||
margin-top: 25px;
|
||||
font-size: 16px;
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
178
frontend_nuxt/components/CommentEditor.vue
Normal file
178
frontend_nuxt/components/CommentEditor.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<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()
|
||||
console.debug('CommentEditor submit', value)
|
||||
emit('submit', value, () => {
|
||||
if (!vditorInstance.value) return
|
||||
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>
|
||||
302
frontend_nuxt/components/CommentItem.vue
Normal file
302
frontend_nuxt/components/CommentItem.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<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
|
||||
}
|
||||
console.debug('Deleting comment', props.comment.id)
|
||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
console.debug('Delete comment response status', res.status)
|
||||
if (res.ok) {
|
||||
toast.success('已删除')
|
||||
emit('deleted', props.comment.id)
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
const submitReply = async (text, clear) => {
|
||||
if (!text.trim()) return
|
||||
isWaitingForReply.value = true
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
isWaitingForReply.value = false
|
||||
return
|
||||
}
|
||||
console.debug('Submitting reply', { parentId: props.comment.id, text })
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content: text })
|
||||
})
|
||||
console.debug('Submit reply response status', res.status)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
console.debug('Submit reply response data', data)
|
||||
const replyList = props.comment.reply || (props.comment.reply = [])
|
||||
replyList.push({
|
||||
id: data.id,
|
||||
userName: data.author.username,
|
||||
time: TimeManager.format(data.createdAt),
|
||||
avatar: data.author.avatar,
|
||||
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}`)
|
||||
})
|
||||
clear()
|
||||
showEditor.value = false
|
||||
toast.success('回复成功')
|
||||
} else if (res.status === 429) {
|
||||
toast.error('回复过于频繁,请稍后再试')
|
||||
} else {
|
||||
toast.error(`回复失败: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Submit reply error', e)
|
||||
toast.error(`回复失败: ${e.message}`)
|
||||
} finally {
|
||||
isWaitingForReply.value = false
|
||||
}
|
||||
}
|
||||
const copyCommentLink = () => {
|
||||
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
toast.success('已复制')
|
||||
})
|
||||
}
|
||||
const handleContentClick = e => {
|
||||
handleMarkdownClick(e)
|
||||
if (e.target.tagName === 'IMG') {
|
||||
const container = e.target.parentNode
|
||||
const imgs = [...container.querySelectorAll('img')].map(i => i.src)
|
||||
lightboxImgs.value = imgs
|
||||
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||
lightboxVisible.value = true
|
||||
}
|
||||
}
|
||||
return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply, commentMenuItems, deleteComment, lightboxVisible, lightboxIndex, lightboxImgs, handleContentClick, loggedIn, replyCount, replyList }
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
59
frontend_nuxt/components/LevelProgress.vue
Normal file
59
frontend_nuxt/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>
|
||||
85
frontend_nuxt/components/LoginOverlay.vue
Normal file
85
frontend_nuxt/components/LoginOverlay.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<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);
|
||||
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>
|
||||
247
frontend_nuxt/components/MilkTeaActivityComponent.vue
Normal file
247
frontend_nuxt/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_nuxt/components/NotificationContainer.vue
Normal file
63
frontend_nuxt/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>
|
||||
150
frontend_nuxt/components/PostEditor.vue
Normal file
150
frontend_nuxt/components/PostEditor.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<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', 'update:loading'],
|
||||
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)
|
||||
let vditorRender = false
|
||||
|
||||
const getEditorTheme = getEditorThemeUtil
|
||||
const getPreviewTheme = getPreviewThemeUtil
|
||||
const applyTheme = () => {
|
||||
if (vditorInstance.value) {
|
||||
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
val => {
|
||||
if (!vditorRender) return
|
||||
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(() => {
|
||||
emit('update:loading', true)
|
||||
vditorInstance.value = createVditor(props.editorId, {
|
||||
placeholder: '请输入正文...',
|
||||
input(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
after() {
|
||||
vditorRender = true
|
||||
emit('update:loading', false)
|
||||
vditorInstance.value.setValue(props.modelValue)
|
||||
if (props.loading || props.disabled) {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
})
|
||||
// applyTheme()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearVditorStorage()
|
||||
})
|
||||
|
||||
return {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-editor-container {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-editor-container {
|
||||
min-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
37
frontend_nuxt/components/ProgressBar.vue
Normal file
37
frontend_nuxt/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>
|
||||
308
frontend_nuxt/components/ReactionsGroup.vue
Normal file
308
frontend_nuxt/components/ReactionsGroup.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<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">{{ reactionEmojiMap[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) }">
|
||||
{{ reactionEmojiMap[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'
|
||||
import { reactionEmojiMap } from '../utils/reactions'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
reactionEmojiMap,
|
||||
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>
|
||||
65
frontend_nuxt/components/UserList.vue
Normal file
65
frontend_nuxt/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>
|
||||
1
frontend_nuxt/constants.js
Normal file
1
frontend_nuxt/constants.js
Normal file
@@ -0,0 +1 @@
|
||||
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'
|
||||
119
frontend_nuxt/package-lock.json
generated
119
frontend_nuxt/package-lock.json
generated
@@ -6,10 +6,15 @@
|
||||
"": {
|
||||
"name": "frontend_nuxt",
|
||||
"dependencies": {
|
||||
"cropperjs": "^1.6.2",
|
||||
"echarts": "^5.6.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ldrs": "^1.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"nuxt": "latest"
|
||||
"nuxt": "latest",
|
||||
"vditor": "^3.11.1",
|
||||
"vue-easy-lightbox": "^1.19.0",
|
||||
"vue-echarts": "^7.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -3455,6 +3460,12 @@
|
||||
"node": ">=18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cropperjs": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"license": "MIT",
|
||||
@@ -3907,6 +3918,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-match-patch": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
@@ -3997,6 +4014,22 @@
|
||||
"version": "0.2.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT"
|
||||
@@ -9787,6 +9820,18 @@
|
||||
"spdx-expression-parse": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vditor": {
|
||||
"version": "3.11.1",
|
||||
"resolved": "https://registry.npmjs.org/vditor/-/vditor-3.11.1.tgz",
|
||||
"integrity": "sha512-7rjNSXYVyZG0mVZpUG2tfxwnoNtkcRCnwdSju+Zvpjf/r72iQa6kLpeThFMIKPuQ5CRnQQv6gnR3eNU6UGbC2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"diff-match-patch": "^1.0.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ld246.com/sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.0",
|
||||
"license": "MIT",
|
||||
@@ -10113,10 +10158,67 @@
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-demi": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
|
||||
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-devtools-stub": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-easy-lightbox": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-easy-lightbox/-/vue-easy-lightbox-1.19.0.tgz",
|
||||
"integrity": "sha512-YxLXgjEn91UF3DuK1y8u3Pyx2sJ7a/MnBpkyrBSQkvU1glzEJASyAZ7N+5yDpmxBQDVMwCsL2VmxWGIiFrWCgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.18.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-echarts": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz",
|
||||
"integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": "^0.13.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/runtime-core": "^3.0.0",
|
||||
"echarts": "^5.5.1",
|
||||
"vue": "^2.7.0 || ^3.1.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/runtime-core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.5.1",
|
||||
"license": "MIT",
|
||||
@@ -10505,6 +10607,21 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
|
||||
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"ldrs": "^1.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"nuxt": "latest"
|
||||
"nuxt": "latest",
|
||||
"cropperjs": "^1.6.2",
|
||||
"echarts": "^5.6.0",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-easy-lightbox": "^1.19.0",
|
||||
"vditor": "^3.11.1"
|
||||
}
|
||||
}
|
||||
|
||||
33
frontend_nuxt/pages/404.vue
Normal file
33
frontend_nuxt/pages/404.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="not-found-page">
|
||||
<h1>404 - 页面不存在</h1>
|
||||
<p>你访问的页面不存在或已被删除</p>
|
||||
<router-link to="/">返回首页</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'NotFoundPageView'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.not-found-page h1 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.not-found-page a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
122
frontend_nuxt/pages/about/index.vue
Normal file
122
frontend_nuxt/pages/about/index.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<div class="about-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:class="['about-tabs-item', { selected: selectedTab === tab.name }]"
|
||||
@click="selectTab(tab.name)"
|
||||
>
|
||||
<div class="about-tabs-item-label">{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-content" v-html="renderMarkdown(content)" @click="handleContentClick"></div>
|
||||
<div class="about-loading" v-if="isFetching">
|
||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'AboutPageView',
|
||||
setup() {
|
||||
const isFetching = ref(false)
|
||||
const tabs = [
|
||||
{ name: 'about', label: '关于', file: '/about_markdown/about.md' },
|
||||
{ name: 'agreement', label: '用户协议', file: '/about_markdown/agreement.md' },
|
||||
{ name: 'guideline', label: '创作准则', file: '/about_markdown/guideline.md' },
|
||||
{ name: 'privacy', label: '隐私政策', file: '/about_markdown/privacy.md' },
|
||||
]
|
||||
const selectedTab = ref(tabs[0].name)
|
||||
const content = ref('')
|
||||
|
||||
const loadContent = async (file) => {
|
||||
try {
|
||||
isFetching.value = true
|
||||
const res = await fetch(file)
|
||||
if (res.ok) {
|
||||
content.value = await res.text()
|
||||
} else {
|
||||
content.value = '# 内容加载失败'
|
||||
}
|
||||
} catch (e) {
|
||||
content.value = '# 内容加载失败'
|
||||
}
|
||||
isFetching.value = false
|
||||
}
|
||||
|
||||
const selectTab = (name) => {
|
||||
selectedTab.value = name
|
||||
const tab = tabs.find(t => t.name === name)
|
||||
if (tab) loadContent(tab.file)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadContent(tabs[0].file)
|
||||
})
|
||||
|
||||
const handleContentClick = e => {
|
||||
handleMarkdownClick(e)
|
||||
}
|
||||
|
||||
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about-page {
|
||||
max-width: var(--page-max-width);
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.about-tabs {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + 1px);
|
||||
z-index: 200;
|
||||
background-color: var(--background-color-blur);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.about-tabs-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.about-tabs-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.about-content {
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.about-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-tabs {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
53
frontend_nuxt/pages/about/stats.vue
Normal file
53
frontend_nuxt/pages/about/stats.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="site-stats-page">
|
||||
<VChart v-if="option" :option="option" :autoresize="true" style="height:400px" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { TitleComponent, TooltipComponent, GridComponent, DataZoomComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { getToken } from '../utils/auth'
|
||||
|
||||
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||
|
||||
const option = ref(null)
|
||||
|
||||
async function loadData() {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/stats/dau-range?days=30`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
data.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
const dates = data.map(d => d.date)
|
||||
const values = data.map(d => d.value)
|
||||
option.value = {
|
||||
title: { text: '站点 DAU' },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: dates },
|
||||
yAxis: { type: 'value' },
|
||||
dataZoom: [{ type: 'slider', start: 80 }, { type: 'inside' }],
|
||||
series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.site-stats-page {
|
||||
padding: 20px;
|
||||
max-width: var(--page-max-width);
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
169
frontend_nuxt/pages/activities.vue
Normal file
169
frontend_nuxt/pages/activities.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="activity-list-page">
|
||||
<div v-if="isLoadingActivities" class="loading-activities">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div class="activity-list-page-card" v-for="a in activities" :key="a.id">
|
||||
<div class="activity-list-page-card-normal">
|
||||
<div v-if="a.icon" class="activity-card-normal-left">
|
||||
<img :src="a.icon" alt="avatar" class="activity-card-left-avatar-img" />
|
||||
</div>
|
||||
<div class="activity-card-normal-right">
|
||||
<div class="activity-card-normal-right-header">
|
||||
<div class="activity-list-page-card-title">{{ a.title }}</div>
|
||||
<div v-if="a.ended" class="activity-list-page-card-state-end">已结束</div>
|
||||
<div v-else class="activity-list-page-card-state-ongoing">进行中</div>
|
||||
</div>
|
||||
<div class="activity-list-page-card-content">{{ a.content }}</div>
|
||||
<div class="activity-list-page-card-footer">
|
||||
<div class="activity-list-page-card-footer-start-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>开始于 {{ TimeManager.format(a.startTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL } from '../main'
|
||||
import TimeManager from '../utils/time'
|
||||
import MilkTeaActivityComponent from '../components/MilkTeaActivityComponent.vue'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'ActivityListPageView',
|
||||
components: { MilkTeaActivityComponent },
|
||||
data() {
|
||||
return {
|
||||
activities: [],
|
||||
TimeManager,
|
||||
isLoadingActivities: false
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.isLoadingActivities = true
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
if (res.ok) {
|
||||
this.activities = await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.isLoadingActivities = false
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-activities {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.activity-list-page {
|
||||
background-color: var(--background-color);
|
||||
padding: 20px;
|
||||
height: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-list-page-card {
|
||||
padding: 10px;
|
||||
width: calc(100% - 20px);
|
||||
gap: 10px;
|
||||
background-color: var(--activity-card-background-color);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.activity-card-left-avatar-img {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 10%;
|
||||
object-fit: cover;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.activity-card-normal-right-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.activity-list-page-card-normal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.activity-list-page-card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.activity-list-page-card-content {
|
||||
font-size: 1.0rem;
|
||||
margin-top: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.activity-list-page-card-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.activity-list-page-card-state-end,
|
||||
.activity-list-page-card-state-ongoing {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.activity-list-page-card-state-end {
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.activity-list-page-card-state-ongoing {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.activity-list-page-card-footer-start-time {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.activity-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.activity-card-left-avatar-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.activity-list-page-card-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.activity-list-page-card-content {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
26
frontend_nuxt/pages/discord-callback.vue
Normal file
26
frontend_nuxt/pages/discord-callback.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CallbackPage from '../components/CallbackPage.vue'
|
||||
import { discordExchange } from '../utils/discord'
|
||||
|
||||
export default {
|
||||
name: 'DiscordCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await discordExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
this.$router.push('/signup-reason?token=' + result.token)
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
175
frontend_nuxt/pages/forgot-password.vue
Normal file
175
frontend_nuxt/pages/forgot-password.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
<div class="primary-button" @click="sendCode" v-if="!isSending">发送验证码</div>
|
||||
<div class="primary-button disabled" v-else>发送中...</div>
|
||||
</div>
|
||||
<div v-else-if="step === 1" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="code" placeholder="邮箱验证码" />
|
||||
<div class="primary-button" @click="verifyCode" v-if="!isVerifying">验证</div>
|
||||
<div class="primary-button disabled" v-else>验证中...</div>
|
||||
</div>
|
||||
<div v-else class="step-content">
|
||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="新密码" />
|
||||
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import BaseInput from '../components/BaseInput.vue'
|
||||
export default {
|
||||
name: 'ForgotPasswordPageView',
|
||||
components: { BaseInput },
|
||||
data() {
|
||||
return {
|
||||
step: 0,
|
||||
email: '',
|
||||
code: '',
|
||||
password: '',
|
||||
token: '',
|
||||
emailError: '',
|
||||
passwordError: '',
|
||||
isSending: false,
|
||||
isVerifying: false,
|
||||
isResetting: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.email) {
|
||||
this.email = decodeURIComponent(this.$route.query.email)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async sendCode() {
|
||||
if (!this.email) {
|
||||
this.emailError = '邮箱不能为空'
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isSending = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.email })
|
||||
})
|
||||
this.isSending = false
|
||||
if (res.ok) {
|
||||
toast.success('验证码已发送')
|
||||
this.step = 1
|
||||
} else {
|
||||
toast.error('请填写已注册邮箱')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isSending = false
|
||||
toast.error('发送失败')
|
||||
}
|
||||
},
|
||||
async verifyCode() {
|
||||
try {
|
||||
this.isVerifying = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.email, code: this.code })
|
||||
})
|
||||
this.isVerifying = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
this.token = data.token
|
||||
this.step = 2
|
||||
} else {
|
||||
toast.error(data.error || '验证失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isVerifying = false
|
||||
toast.error('验证失败')
|
||||
}
|
||||
},
|
||||
async resetPassword() {
|
||||
if (!this.password) {
|
||||
this.passwordError = '密码不能为空'
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isResetting = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: this.token, password: this.password })
|
||||
})
|
||||
this.isResetting = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success('密码已重置')
|
||||
this.$router.push('/login')
|
||||
} else if (data.field === 'password') {
|
||||
this.passwordError = data.error
|
||||
} else {
|
||||
toast.error(data.error || '重置失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isResetting = false
|
||||
toast.error('重置失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forgot-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--background-color);
|
||||
height: 100%;
|
||||
}
|
||||
.forgot-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 400px;
|
||||
}
|
||||
.forgot-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.primary-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
.primary-button.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.forgot-content {
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
26
frontend_nuxt/pages/github-callback.vue
Normal file
26
frontend_nuxt/pages/github-callback.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CallbackPage from '../components/CallbackPage.vue'
|
||||
import { githubExchange } from '../utils/github'
|
||||
|
||||
export default {
|
||||
name: 'GithubCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await githubExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
this.$router.push('/signup-reason?token=' + result.token)
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
27
frontend_nuxt/pages/google-callback.vue
Normal file
27
frontend_nuxt/pages/google-callback.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CallbackPage from '../components/CallbackPage.vue'
|
||||
import { googleAuthWithToken } from '../utils/google'
|
||||
|
||||
export default {
|
||||
name: 'GoogleCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||
const idToken = hash.get('id_token')
|
||||
if (idToken) {
|
||||
await googleAuthWithToken(idToken, () => {
|
||||
this.$router.push('/')
|
||||
}, token => {
|
||||
this.$router.push('/signup-reason?token=' + token)
|
||||
})
|
||||
} else {
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
302
frontend_nuxt/pages/login.vue
Normal file
302
frontend_nuxt/pages/login.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-page-content">
|
||||
<div class="login-page-header">
|
||||
<div class="login-page-header-title">
|
||||
Welcome :)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-login-page-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="username" placeholder="邮箱/用户名" />
|
||||
|
||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="密码" />
|
||||
|
||||
|
||||
<div v-if="!isWaitingForLogin" class="login-page-button-primary" @click="submitLogin">
|
||||
<div class="login-page-button-text">登录</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="login-page-button-primary disabled">
|
||||
<div class="login-page-button-text">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
登录中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-page-button-secondary">没有账号? <a class="login-page-button-secondary-link" href="/signup">注册</a> |
|
||||
<a class="login-page-button-secondary-link" :href="`/forgot-password?email=${username}`">找回密码</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="other-login-page-content">
|
||||
<div class="login-page-button" @click="googleAuthorize">
|
||||
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="login-page-button-text">Google 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithGithub">
|
||||
<img class="login-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
|
||||
<div class="login-page-button-text">GitHub 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithDiscord">
|
||||
<img class="login-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
|
||||
<div class="login-page-button-text">Discord 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithTwitter">
|
||||
<img class="login-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||
<div class="login-page-button-text">Twitter 登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from '../utils/auth'
|
||||
import { googleAuthorize } from '../utils/google'
|
||||
import { githubAuthorize } from '../utils/github'
|
||||
import { discordAuthorize } from '../utils/discord'
|
||||
import { twitterAuthorize } from '../utils/twitter'
|
||||
import BaseInput from '../components/BaseInput.vue'
|
||||
import { registerPush } from '../utils/push'
|
||||
export default {
|
||||
name: 'LoginPageView',
|
||||
components: { BaseInput },
|
||||
setup() {
|
||||
return { googleAuthorize }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
isWaitingForLogin: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitLogin() {
|
||||
try {
|
||||
this.isWaitingForLogin = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: this.username, password: this.password })
|
||||
})
|
||||
this.isWaitingForLogin = false
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
this.$router.push('/')
|
||||
} else if (data.reason_code === 'NOT_VERIFIED') {
|
||||
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册正在审批中, 请留意邮件')
|
||||
this.$router.push('/')
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
this.$router.push('/signup-reason?token=' + data.token)
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('登录失败')
|
||||
}
|
||||
},
|
||||
|
||||
loginWithGithub() {
|
||||
githubAuthorize()
|
||||
},
|
||||
loginWithDiscord() {
|
||||
discordAuthorize()
|
||||
},
|
||||
loginWithTwitter() {
|
||||
twitterAuthorize()
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.login-page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(40% - 120px);
|
||||
border-right: 1px solid var(--normal-border-color);
|
||||
padding-right: 120px;
|
||||
}
|
||||
|
||||
.login-page-header-title {
|
||||
font-family: 'Pacifico', 'Comic Sans MS', cursive, 'Roboto', sans-serif;
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.login-page-header {
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.email-login-page-content {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-page-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 #ccc;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-page-input-icon {
|
||||
opacity: 0.5;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-page-input-text {
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-primary {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(100% - 40px);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button-primary:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.login-page-button-primary.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-page-button-primary.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
min-width: 150px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.login-page-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-page-button-secondary {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.login-page-button-secondary-link {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.login-page-content {
|
||||
margin-top: 20px;
|
||||
width: calc(100% - 40px);
|
||||
border-right: none;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-primary {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.login-page-button-secondary {
|
||||
margin-top: 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
763
frontend_nuxt/pages/message.vue
Normal file
763
frontend_nuxt/pages/message.vue
Normal file
@@ -0,0 +1,763 @@
|
||||
<template>
|
||||
<div class="message-page">
|
||||
<div class="message-page-header">
|
||||
<div class="message-tabs">
|
||||
<div :class="['message-tab-item', { selected: selectedTab === 'all' }]" @click="selectedTab = 'all'">消息</div>
|
||||
<div :class="['message-tab-item', { selected: selectedTab === 'unread' }]" @click="selectedTab = 'unread'">未读
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-page-header-right">
|
||||
<div class="message-page-header-right-item" @click="markAllRead">
|
||||
<i class="fas fa-bolt message-page-header-right-item-button-icon"></i>
|
||||
<span class="message-page-header-right-item-button-text">
|
||||
已读所有消息
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingMessage" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<BasePlaceholder v-else-if="filteredNotifications.length === 0" text="暂时没有消息 :)" icon="fas fa-inbox" />
|
||||
|
||||
<div class="timeline-container" v-if="filteredNotifications.length > 0">
|
||||
<BaseTimeline :items="filteredNotifications">
|
||||
<template #item="{ item }">
|
||||
<div class="notif-content" :class="{ read: item.read }">
|
||||
<span v-if="!item.read" class="unread-dot"></span>
|
||||
<span class="notif-type">
|
||||
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`">{{ item.comment.author.username }} </router-link> 对我的评论
|
||||
<span>
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||
</router-link>
|
||||
</span> 回复了 <span>
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</router-link>
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`">{{ item.comment.author.username }} </router-link> 对我的文章
|
||||
<span>
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
</span> 回复了 <span>
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</router-link>
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'ACTIVITY_REDEEM' && !item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<span class="notif-user">{{ item.fromUser.username }} </span> 申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||
<span>
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
</span>
|
||||
进行了表态
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REACTION' && item.comment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`">{{ item.fromUser.username }} </router-link> 对我的评论
|
||||
<span>
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</router-link>
|
||||
</span>
|
||||
进行了表态
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_VIEWED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
查看了您的帖子
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您关注的帖子
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
下面有新评论
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你关注的
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`">
|
||||
{{ item.comment.author.username }}
|
||||
</router-link>
|
||||
在 对评论
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`">
|
||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||
</router-link>
|
||||
回复了
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_ACTIVITY'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你关注的
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`">
|
||||
{{ item.comment.author.username }}
|
||||
</router-link>
|
||||
在文章
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
下面评论了
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'MENTION' && item.comment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
在评论中提到了你:
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`">
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'MENTION'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
在帖子
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
中提到了你
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_FOLLOWED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
开始关注你了
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_UNFOLLOWED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
取消关注你了
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'FOLLOWED_POST'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你关注的
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
发布了文章
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
订阅了你的文章
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
取消订阅了你的文章
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/users/${item.fromUser.id}`">
|
||||
{{ item.fromUser.username }}
|
||||
</router-link>
|
||||
发布了帖子
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
,请审核
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您发布的帖子
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
已提交审核
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REGISTER_REQUEST'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
{{ item.fromUser.username }} 希望注册为会员,理由是:{{ item.content }}
|
||||
<template #actions v-if="authState.role === 'ADMIN'">
|
||||
<div v-if="!item.read" class="optional-buttons">
|
||||
<div class="mark-approve-button-item" @click="approve(item.fromUser.id, item.id)">同意</div>
|
||||
<div class="mark-reject-button-item" @click="reject(item.fromUser.id, item.id)">拒绝</div>
|
||||
</div>
|
||||
<div v-else class="has_read_button" @click="markRead(item.id)">已读</div>
|
||||
</template>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您发布的帖子
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
已审核通过
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您发布的帖子
|
||||
<router-link class="notif-content-text" @click="markRead(item.id)" :to="`/posts/${item.post.id}`">
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
已被管理员拒绝
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
{{ formatType(item.type) }}
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
</span>
|
||||
<span class="notif-time">{{ TimeManager.format(item.createdAt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '../components/BasePlaceholder.vue'
|
||||
import NotificationContainer from '../components/NotificationContainer.vue'
|
||||
import { getToken, authState } from '../utils/auth'
|
||||
import { markNotificationsRead, fetchUnreadCount, notificationState } from '../utils/notification'
|
||||
import { toast } from '../main'
|
||||
import { stripMarkdownLength } from '../utils/markdown'
|
||||
import TimeManager from '../utils/time'
|
||||
import { hatch } from 'ldrs'
|
||||
import { reactionEmojiMap } from '../utils/reactions'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'MessagePageView',
|
||||
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
const selectedTab = ref('unread')
|
||||
const filteredNotifications = computed(() =>
|
||||
selectedTab.value === 'all'
|
||||
? notifications.value
|
||||
: notifications.value.filter(n => !n.read)
|
||||
)
|
||||
|
||||
const markRead = async id => {
|
||||
if (!id) return
|
||||
const n = notifications.value.find(n => n.id === id)
|
||||
if (!n || n.read) return
|
||||
n.read = true
|
||||
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
||||
const ok = await markNotificationsRead([id])
|
||||
if (!ok) {
|
||||
n.read = false
|
||||
notificationState.unreadCount++
|
||||
} else {
|
||||
fetchUnreadCount()
|
||||
}
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
// 除了 REGISTER_REQUEST 类型消息
|
||||
const idsToMark = notifications.value
|
||||
.filter(n => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||
.map(n => n.id)
|
||||
if (idsToMark.length === 0) return
|
||||
notifications.value.forEach(n => {
|
||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||
})
|
||||
notificationState.unreadCount = notifications.value.filter(n => !n.read).length
|
||||
const ok = await markNotificationsRead(idsToMark)
|
||||
if (!ok) {
|
||||
notifications.value.forEach(n => {
|
||||
if (idsToMark.includes(n.id)) n.read = false
|
||||
})
|
||||
await fetchUnreadCount()
|
||||
return
|
||||
}
|
||||
fetchUnreadCount()
|
||||
if (authState.role === 'ADMIN') {
|
||||
toast.success('已读所有消息(注册请求除外)')
|
||||
} else {
|
||||
toast.success('已读所有消息')
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
POST_VIEWED: 'fas fa-eye',
|
||||
COMMENT_REPLY: 'fas fa-reply',
|
||||
POST_REVIEWED: 'fas fa-shield-alt',
|
||||
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
||||
POST_UPDATED: 'fas fa-comment-dots',
|
||||
USER_ACTIVITY: 'fas fa-user',
|
||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
||||
USER_FOLLOWED: 'fas fa-user-plus',
|
||||
USER_UNFOLLOWED: 'fas fa-user-minus',
|
||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
MENTION: 'fas fa-at'
|
||||
}
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
isLoadingMessage.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
isLoadingMessage.value = false
|
||||
if (!res.ok) {
|
||||
toast.error('获取通知失败')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
for (const n of data) {
|
||||
if (n.type === 'COMMENT_REPLY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.comment.author.id}`)
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'REACTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
emoji: reactionEmojiMap[n.reactionType],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'POST_VIEWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'POST_UPDATED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.comment.author.id}`)
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'USER_ACTIVITY') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.comment.author.id}`)
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'MENTION') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'FOLLOWED_POST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (n.type === 'REGISTER_REQUEST') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => { }
|
||||
})
|
||||
} else {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const approve = async (id, nid) => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
markRead(nid)
|
||||
toast.success('已同意')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const reject = async (id, nid) => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
markRead(nid)
|
||||
toast.success('已拒绝')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatType = t => {
|
||||
switch (t) {
|
||||
case 'POST_VIEWED':
|
||||
return '帖子被查看'
|
||||
case 'COMMENT_REPLY':
|
||||
return '有人回复了你'
|
||||
case 'REACTION':
|
||||
return '有人点赞'
|
||||
case 'POST_REVIEW_REQUEST':
|
||||
return '帖子待审核'
|
||||
case 'POST_REVIEWED':
|
||||
return '帖子审核结果'
|
||||
case 'POST_UPDATED':
|
||||
return '关注的帖子有新评论'
|
||||
case 'FOLLOWED_POST':
|
||||
return '关注的用户发布了新文章'
|
||||
case 'POST_SUBSCRIBED':
|
||||
return '有人订阅了你的文章'
|
||||
case 'POST_UNSUBSCRIBED':
|
||||
return '有人取消订阅你的文章'
|
||||
case 'USER_FOLLOWED':
|
||||
return '有人关注了你'
|
||||
case 'USER_UNFOLLOWED':
|
||||
return '有人取消关注你'
|
||||
case 'USER_ACTIVITY':
|
||||
return '关注的用户有新动态'
|
||||
case 'MENTION':
|
||||
return '有人提到了你'
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchNotifications)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
formatType,
|
||||
isLoadingMessage,
|
||||
stripMarkdownLength,
|
||||
markRead,
|
||||
approve,
|
||||
reject,
|
||||
TimeManager,
|
||||
selectedTab,
|
||||
filteredNotifications,
|
||||
markAllRead,
|
||||
authState
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.message-page {
|
||||
background-color: var(--background-color);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.message-page-header {
|
||||
position: sticky;
|
||||
top: 1px;
|
||||
z-index: 200;
|
||||
background-color: var(--background-color-blur);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message-page-header-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message-page-header-right-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
padding-right: 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.message-page-header-right-item-button-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-page-header-right-item-button-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-page-header-right-item-button-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
padding: 10px 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notif-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notif-content.read {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.notif-type {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.notif-content-text {
|
||||
font-weight: bold;
|
||||
color: var(--primary-color) !important;
|
||||
text-decoration: none !important;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.optional-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mark-approve-button-item {
|
||||
color: green;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark-reject-button-item {
|
||||
color: red;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark-approve-button-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mark-reject-button-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.has_read_button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notif-content-text:hover {
|
||||
color: var(--primary-color) !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.notif-user {
|
||||
font-weight: bold;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.message-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.message-tab-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-tab-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.has_read_button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
387
frontend_nuxt/pages/new-post.vue
Normal file
387
frontend_nuxt/pages/new-post.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div class="new-post-page">
|
||||
<div class="new-post-form">
|
||||
<input class="post-title-input" v-model="title" placeholder="标题" />
|
||||
<div class="post-editor-container">
|
||||
<PostEditor v-model="content" v-model:loading="isAiLoading" :disabled="!isLogin" />
|
||||
<LoginOverlay v-if="!isLogin" />
|
||||
</div>
|
||||
<div class="post-options">
|
||||
<div class="post-options-left">
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost">
|
||||
<i class="fa-solid fa-eraser"></i> 清空
|
||||
</div>
|
||||
<div class="ai-generate" @click="aiGenerate">
|
||||
<i class="fa-solid fa-robot"></i>
|
||||
md格式优化
|
||||
</div>
|
||||
<div class="post-draft" @click="saveDraft">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
存草稿
|
||||
</div>
|
||||
<div
|
||||
v-if="!isWaitingPosting"
|
||||
class="post-submit"
|
||||
:class="{ disabled: !isLogin }"
|
||||
@click="submitPost"
|
||||
>发布</div>
|
||||
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import PostEditor from '../components/PostEditor.vue'
|
||||
import CategorySelect from '../components/CategorySelect.vue'
|
||||
import TagSelect from '../components/TagSelect.vue'
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, authState } from '../utils/auth'
|
||||
import LoginOverlay from '../components/LoginOverlay.vue'
|
||||
|
||||
export default {
|
||||
name: 'NewPostPageView',
|
||||
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
||||
setup() {
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
|
||||
const loadDraft = async () => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok && res.status !== 204) {
|
||||
const data = await res.json()
|
||||
title.value = data.title || ''
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.categoryId || ''
|
||||
selectedTags.value = data.tagIds || []
|
||||
|
||||
toast.success('草稿已加载')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDraft)
|
||||
|
||||
const clearPost = async () => {
|
||||
title.value = ''
|
||||
content.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
|
||||
// 删除草稿
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('草稿已清空')
|
||||
} else {
|
||||
toast.error('云端草稿清空失败, 请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveDraft = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const tagIds = selectedTags.value.filter(t => typeof t === 'number')
|
||||
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: title.value,
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value || null,
|
||||
tagIds
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('草稿已保存')
|
||||
} else {
|
||||
toast.error('保存失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('保存失败')
|
||||
}
|
||||
}
|
||||
const ensureTags = async (token) => {
|
||||
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||
const t = selectedTags.value[i]
|
||||
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||
const name = t.slice(8)
|
||||
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ name, description: '' })
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
selectedTags.value[i] = data.id
|
||||
// update local TagSelect options handled by component
|
||||
} else {
|
||||
let data
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch (e) {
|
||||
data = null
|
||||
}
|
||||
toast.error((data && data.error) || '创建标签失败')
|
||||
throw new Error('create tag failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const aiGenerate = async () => {
|
||||
if (!content.value.trim()) {
|
||||
toast.error('内容为空,无法优化')
|
||||
return
|
||||
}
|
||||
isAiLoading.value = true
|
||||
try {
|
||||
toast.info('AI 优化中...')
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ text: content.value })
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
content.value = data.content || ''
|
||||
} else if (res.status === 429) {
|
||||
toast.error('今日AI优化次数已用尽')
|
||||
} else {
|
||||
toast.error('AI 优化失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('AI 优化失败')
|
||||
} finally {
|
||||
isAiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitPost = async () => {
|
||||
if (!title.value.trim()) {
|
||||
toast.error('标题不能为空')
|
||||
return
|
||||
}
|
||||
if (!content.value.trim()) {
|
||||
toast.error('内容不能为空')
|
||||
return
|
||||
}
|
||||
if (!selectedCategory.value) {
|
||||
toast.error('请选择分类')
|
||||
return
|
||||
}
|
||||
if (selectedTags.value.length === 0) {
|
||||
toast.error('请选择标签')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const token = getToken()
|
||||
await ensureTags(token)
|
||||
isWaitingPosting.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: title.value,
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (data.reward && data.reward > 0) {
|
||||
toast.success(`发布成功,获得 ${data.reward} 经验值`)
|
||||
} else {
|
||||
toast.success('发布成功')
|
||||
}
|
||||
if (data.id) {
|
||||
window.location.href = `/posts/${data.id}`
|
||||
}
|
||||
} else if (res.status === 429) {
|
||||
toast.error('发布过于频繁,请稍后再试')
|
||||
} else {
|
||||
toast.error(data.error || '发布失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('发布失败')
|
||||
} finally {
|
||||
isWaitingPosting.value = false
|
||||
}
|
||||
}
|
||||
return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.new-post-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--background-color);
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.new-post-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post-title-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
background-color: transparent;
|
||||
font-size: 42px;
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.post-draft {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-draft:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ai-generate {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-generate:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.post-clear {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.post-editor-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-submit {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-submit.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.post-submit:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
.post-submit.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.post-submit-loading {
|
||||
color: white;
|
||||
background-color: var(--primary-color-disabled);
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
width: fit-content;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.post-options-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-options-right {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
row-gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.new-post-page {
|
||||
width: calc(100vw - 20px);
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.post-title-input {
|
||||
font-size: 24px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-options {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1018
frontend_nuxt/pages/posts/[id].vue
Normal file
1018
frontend_nuxt/pages/posts/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
339
frontend_nuxt/pages/posts/[id]/edit.vue
Normal file
339
frontend_nuxt/pages/posts/[id]/edit.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div class="new-post-page">
|
||||
<div class="new-post-form">
|
||||
<input class="post-title-input" v-model="title" placeholder="标题" />
|
||||
<div class="post-editor-container">
|
||||
<PostEditor v-model="content" :loading="isAiLoading" :disabled="!isLogin" />
|
||||
<LoginOverlay v-if="!isLogin" />
|
||||
</div>
|
||||
<div class="post-options">
|
||||
<div class="post-options-left">
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost">
|
||||
<i class="fa-solid fa-eraser"></i> 清空
|
||||
</div>
|
||||
<div class="ai-generate" @click="aiGenerate">
|
||||
<i class="fa-solid fa-robot"></i>
|
||||
md格式优化
|
||||
</div>
|
||||
<div class="post-cancel" @click="cancelEdit">
|
||||
取消
|
||||
</div>
|
||||
<div
|
||||
v-if="!isWaitingPosting"
|
||||
class="post-submit"
|
||||
:class="{ disabled: !isLogin }"
|
||||
@click="submitPost"
|
||||
>更新</div>
|
||||
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 更新中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PostEditor from '../components/PostEditor.vue'
|
||||
import CategorySelect from '../components/CategorySelect.vue'
|
||||
import TagSelect from '../components/TagSelect.vue'
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, authState } from '../utils/auth'
|
||||
import LoginOverlay from '../components/LoginOverlay.vue'
|
||||
|
||||
export default {
|
||||
name: 'EditPostPageView',
|
||||
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
||||
setup() {
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const postId = route.params.id
|
||||
|
||||
const loadPost = async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
title.value = data.title || ''
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.category.id || ''
|
||||
selectedTags.value = (data.tags || []).map(t => t.id)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPost)
|
||||
|
||||
const clearPost = () => {
|
||||
title.value = ''
|
||||
content.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
}
|
||||
|
||||
const ensureTags = async (token) => {
|
||||
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||
const t = selectedTags.value[i]
|
||||
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||
const name = t.slice(8)
|
||||
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ name, description: '' })
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
selectedTags.value[i] = data.id
|
||||
// update local TagSelect options handled by component
|
||||
} else {
|
||||
let data
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch (e) {
|
||||
data = null
|
||||
}
|
||||
toast.error((data && data.error) || '创建标签失败')
|
||||
throw new Error('create tag failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const aiGenerate = async () => {
|
||||
if (!content.value.trim()) {
|
||||
toast.error('内容为空,无法优化')
|
||||
return
|
||||
}
|
||||
isAiLoading.value = true
|
||||
try {
|
||||
toast.info('AI 优化中...')
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ text: content.value })
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
content.value = data.content || ''
|
||||
} else if (res.status === 429) {
|
||||
toast.error('今日AI优化次数已用尽')
|
||||
} else {
|
||||
toast.error('AI 优化失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('AI 优化失败')
|
||||
} finally {
|
||||
isAiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitPost = async () => {
|
||||
if (!title.value.trim()) {
|
||||
toast.error('标题不能为空')
|
||||
return
|
||||
}
|
||||
if (!content.value.trim()) {
|
||||
toast.error('内容不能为空')
|
||||
return
|
||||
}
|
||||
if (!selectedCategory.value) {
|
||||
toast.error('请选择分类')
|
||||
return
|
||||
}
|
||||
if (selectedTags.value.length === 0) {
|
||||
toast.error('请选择标签')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const token = getToken()
|
||||
await ensureTags(token)
|
||||
isWaitingPosting.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: title.value,
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success('更新成功')
|
||||
window.location.href = `/posts/${postId}`
|
||||
} else {
|
||||
toast.error(data.error || '更新失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('更新失败')
|
||||
} finally {
|
||||
isWaitingPosting.value = false
|
||||
}
|
||||
}
|
||||
const cancelEdit = () => {
|
||||
router.push(`/posts/${postId}`)
|
||||
}
|
||||
return { title, content, selectedCategory, selectedTags, submitPost, clearPost, cancelEdit, isWaitingPosting, aiGenerate, isAiLoading, isLogin }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.new-post-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--background-color);
|
||||
height: 100%;
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.new-post-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post-title-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
background-color: transparent;
|
||||
font-size: 42px;
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.post-cancel {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-cancel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ai-generate {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-generate:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.post-clear {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.post-editor-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-submit {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-submit.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.post-submit:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
.post-submit.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.post-submit-loading {
|
||||
color: white;
|
||||
background-color: var(--primary-color-disabled);
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
width: fit-content;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.post-options-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-options-right {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
row-gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.post-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.new-post-page {
|
||||
width: calc(100vw - 20px);
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.post-title-input {
|
||||
font-size: 24px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-options {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
376
frontend_nuxt/pages/settings.vue
Normal file
376
frontend_nuxt/pages/settings.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<AvatarCropper
|
||||
:src="tempAvatar"
|
||||
:show="showCropper"
|
||||
@close="showCropper = false"
|
||||
@crop="onCropped"
|
||||
/>
|
||||
<div v-if="isLoadingPage" class="loading-page">
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="settings-title">个人资料设置</div>
|
||||
<div class="profile-section">
|
||||
|
||||
<div class="avatar-row">
|
||||
<!-- label 充当点击区域,内部隐藏 input -->
|
||||
<label class="avatar-container">
|
||||
<img :src="avatar" class="avatar-preview" alt="avatar" />
|
||||
<!-- 半透明蒙层:hover 时出现 -->
|
||||
<div class="avatar-overlay">更换头像</div>
|
||||
<input type="file" class="avatar-input" accept="image/*" @change="onAvatarChange" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row username-row">
|
||||
<BaseInput icon="fas fa-user" v-model="username" @input="usernameError = ''" placeholder="用户名" />
|
||||
<div class="setting-description">用户名是你在社区的唯一标识</div>
|
||||
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
||||
</div>
|
||||
<div class="form-row introduction-row">
|
||||
<div class="setting-title">自我介绍</div>
|
||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||
<h3>管理员设置</h3>
|
||||
<div class="form-row dropdown-row">
|
||||
<div class="setting-title">发布规则</div>
|
||||
<Dropdown v-model="publishMode" :fetch-options="fetchPublishModes" />
|
||||
</div>
|
||||
<div class="form-row dropdown-row">
|
||||
<div class="setting-title">密码强度</div>
|
||||
<Dropdown v-model="passwordStrength" :fetch-options="fetchPasswordStrengths" />
|
||||
</div>
|
||||
<div class="form-row dropdown-row">
|
||||
<div class="setting-title">AI 优化次数</div>
|
||||
<Dropdown v-model="aiFormatLimit" :fetch-options="fetchAiLimits" />
|
||||
</div>
|
||||
<div class="form-row dropdown-row">
|
||||
<div class="setting-title">注册模式</div>
|
||||
<Dropdown v-model="registerMode" :fetch-options="fetchRegisterModes" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<div v-if="isSaving" class="save-button disabled">保存中...</div>
|
||||
<div v-else @click="save" class="save-button">保存</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, fetchCurrentUser, setToken } from '../utils/auth'
|
||||
import BaseInput from '../components/BaseInput.vue'
|
||||
import Dropdown from '../components/Dropdown.vue'
|
||||
import AvatarCropper from '../components/AvatarCropper.vue'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
export default {
|
||||
name: 'SettingsPageView',
|
||||
components: { BaseInput, Dropdown, AvatarCropper },
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
introduction: '',
|
||||
usernameError: '',
|
||||
avatar: '',
|
||||
avatarFile: null,
|
||||
tempAvatar: '',
|
||||
showCropper: false,
|
||||
role: '',
|
||||
publishMode: 'DIRECT',
|
||||
passwordStrength: 'LOW',
|
||||
aiFormatLimit: 3,
|
||||
registerMode: 'DIRECT',
|
||||
isLoadingPage: false,
|
||||
isSaving: false
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.isLoadingPage = true
|
||||
const user = await fetchCurrentUser()
|
||||
|
||||
if (user) {
|
||||
this.username = user.username
|
||||
this.introduction = user.introduction || ''
|
||||
this.avatar = user.avatar
|
||||
this.role = user.role
|
||||
if (this.role === 'ADMIN') {
|
||||
this.loadAdminConfig()
|
||||
}
|
||||
} else {
|
||||
toast.error('请先登录')
|
||||
this.$router.push('/login')
|
||||
}
|
||||
this.isLoadingPage = false
|
||||
},
|
||||
methods: {
|
||||
onAvatarChange(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
this.tempAvatar = reader.result
|
||||
this.showCropper = true
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
},
|
||||
onCropped({ file, url }) {
|
||||
this.avatarFile = file
|
||||
this.avatar = url
|
||||
},
|
||||
fetchPublishModes() {
|
||||
return Promise.resolve([
|
||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' }
|
||||
])
|
||||
},
|
||||
fetchPasswordStrengths() {
|
||||
return Promise.resolve([
|
||||
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
||||
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
||||
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' }
|
||||
])
|
||||
},
|
||||
fetchAiLimits() {
|
||||
return Promise.resolve([
|
||||
{ id: 3, name: '3次' },
|
||||
{ id: 5, name: '5次' },
|
||||
{ id: 10, name: '10次' },
|
||||
{ id: -1, name: '无限' }
|
||||
])
|
||||
},
|
||||
fetchRegisterModes() {
|
||||
return Promise.resolve([
|
||||
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
||||
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' }
|
||||
])
|
||||
},
|
||||
async loadAdminConfig() {
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
this.publishMode = data.publishMode
|
||||
this.passwordStrength = data.passwordStrength
|
||||
this.aiFormatLimit = data.aiFormatLimit
|
||||
this.registerMode = data.registerMode
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
this.isSaving = true
|
||||
|
||||
do {
|
||||
let token = getToken()
|
||||
this.usernameError = ''
|
||||
if (!this.username) {
|
||||
this.usernameError = '用户名不能为空'
|
||||
}
|
||||
if (this.usernameError) {
|
||||
toast.error(this.usernameError)
|
||||
break
|
||||
}
|
||||
if (this.avatarFile) {
|
||||
const form = new FormData()
|
||||
form.append('file', this.avatarFile)
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
this.avatar = data.url
|
||||
} else {
|
||||
toast.error(data.error || '上传失败')
|
||||
break
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ username: this.username, introduction: this.introduction })
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || '保存失败')
|
||||
break
|
||||
}
|
||||
if (data.token) {
|
||||
setToken(data.token)
|
||||
token = data.token
|
||||
}
|
||||
if (this.role === 'ADMIN') {
|
||||
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ publishMode: this.publishMode, passwordStrength: this.passwordStrength, aiFormatLimit: this.aiFormatLimit, registerMode: this.registerMode })
|
||||
})
|
||||
}
|
||||
toast.success('保存成功')
|
||||
} while (!this.isSaving)
|
||||
|
||||
this.isSaving = false
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
background-color: var(--background-color);
|
||||
padding: 40px;
|
||||
height: calc(100% - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.username-row {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.setting-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.introduction-row {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.dropdown-row {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 隐藏默认文件选择按钮 */
|
||||
.avatar-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 蒙层:初始透明,hover 时渐显 */
|
||||
.avatar-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 40px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* hover 触发 */
|
||||
.avatar-container:hover .avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
width: calc(100% - 40px);
|
||||
margin-top: -10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
margin-top: 40px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.save-button.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.save-button.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
142
frontend_nuxt/pages/signup-reason.vue
Normal file
142
frontend_nuxt/pages/signup-reason.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="reason-page">
|
||||
<div class="reason-content">
|
||||
<div class="reason-title">请填写注册理由</div>
|
||||
<div class="reason-description">
|
||||
为了我们社区的良性发展,请填写注册理由,我们将根据你的理由审核你的注册, 谢谢!
|
||||
</div>
|
||||
<div class="reason-input-container">
|
||||
<BaseInput textarea rows="4" v-model="reason" placeholder="20个字以上"></BaseInput>
|
||||
<div class="char-count">{{ reason.length }}/20</div>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">提交</div>
|
||||
<div v-else class="signup-page-button-primary disabled">提交中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseInput from '../components/BaseInput.vue'
|
||||
import {API_BASE_URL, toast} from '../main'
|
||||
|
||||
export default {
|
||||
name: 'SignupReasonPageView',
|
||||
components: {BaseInput},
|
||||
data() {
|
||||
return {
|
||||
reason: '',
|
||||
error: '',
|
||||
isWaitingForRegister: false,
|
||||
token: '',
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.token = this.$route.query.token || ''
|
||||
if (!this.token) {
|
||||
this.$router.push('/signup')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
if (!this.reason || this.reason.trim().length < 20) {
|
||||
this.error = '请至少输入20个字'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.isWaitingForRegister = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
reason: this.reason
|
||||
})
|
||||
})
|
||||
this.isWaitingForRegister = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success('注册理由已提交,请等待审核')
|
||||
this.$router.push('/')
|
||||
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
|
||||
toast.error('登录已过期,请重新登录')
|
||||
this.$router.push('/login')
|
||||
} else {
|
||||
toast.error(data.error || '提交失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isWaitingForRegister = false
|
||||
toast.error('提交失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reason-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--background-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.reason-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.reason-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reason-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.signup-page-button-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signup-page-button-primary:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.signup-page-button-primary.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.signup-page-button-primary.disabled:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.reason-content {
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
412
frontend_nuxt/pages/signup.vue
Normal file
412
frontend_nuxt/pages/signup.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div class="signup-page">
|
||||
<div class="signup-page-content">
|
||||
<div class="signup-page-header">
|
||||
<div class="signup-page-header-title">
|
||||
Welcome :)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="emailStep === 0" class="email-signup-page-content">
|
||||
<BaseInput
|
||||
icon="fas fa-envelope"
|
||||
v-model="email"
|
||||
@input="emailError = ''"
|
||||
placeholder="邮箱"
|
||||
/>
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
|
||||
<BaseInput
|
||||
icon="fas fa-user"
|
||||
v-model="username"
|
||||
@input="usernameError = ''"
|
||||
placeholder="用户名"
|
||||
/>
|
||||
<div v-if="usernameError" class="error-message">{{ usernameError }}</div>
|
||||
|
||||
<BaseInput
|
||||
icon="fas fa-lock"
|
||||
v-model="password"
|
||||
@input="passwordError = ''"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
/>
|
||||
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||
|
||||
|
||||
<div v-if="!isWaitingForEmailSent" class="signup-page-button-primary" @click="sendVerification">
|
||||
<div class="signup-page-button-text">验证邮箱</div>
|
||||
</div>
|
||||
<div v-else class="signup-page-button-primary disabled">
|
||||
<div class="signup-page-button-text">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
发送中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="signup-page-button-secondary">已经有账号? <a class="signup-page-button-secondary-link"
|
||||
href="/login">登录</a></div>
|
||||
</div>
|
||||
|
||||
<div v-if="emailStep === 1" class="email-signup-page-content">
|
||||
<BaseInput
|
||||
icon="fas fa-envelope"
|
||||
v-model="code"
|
||||
placeholder="邮箱验证码"
|
||||
/>
|
||||
<div v-if="!isWaitingForEmailVerified" class="signup-page-button-primary" @click="verifyCode">
|
||||
<div class="signup-page-button-text">注册</div>
|
||||
</div>
|
||||
<div v-else class="signup-page-button-primary disabled">
|
||||
<div class="signup-page-button-text">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
验证中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="other-signup-page-content">
|
||||
<div class="signup-page-button" @click="googleAuthorize">
|
||||
<img class="signup-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="signup-page-button-text">Google 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithGithub">
|
||||
<img class="signup-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
|
||||
<div class="signup-page-button-text">GitHub 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithDiscord">
|
||||
<img class="signup-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
|
||||
<div class="signup-page-button-text">Discord 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithTwitter">
|
||||
<img class="signup-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||
<div class="signup-page-button-text">Twitter 注册</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { googleAuthorize } from '../utils/google'
|
||||
import { githubAuthorize } from '../utils/github'
|
||||
import { discordAuthorize } from '../utils/discord'
|
||||
import { twitterAuthorize } from '../utils/twitter'
|
||||
import BaseInput from '../components/BaseInput.vue'
|
||||
export default {
|
||||
name: 'SignupPageView',
|
||||
components: { BaseInput },
|
||||
setup() {
|
||||
return { googleAuthorize }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
emailStep: 0,
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
registerMode: 'DIRECT',
|
||||
emailError: '',
|
||||
usernameError: '',
|
||||
passwordError: '',
|
||||
code: '',
|
||||
isWaitingForEmailSent: false,
|
||||
isWaitingForEmailVerified: false
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.username = this.$route.query.u || ''
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
this.registerMode = data.registerMode
|
||||
}
|
||||
} catch {/* ignore */}
|
||||
if (this.$route.query.verify) {
|
||||
this.emailStep = 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearErrors() {
|
||||
this.emailError = ''
|
||||
this.usernameError = ''
|
||||
this.passwordError = ''
|
||||
},
|
||||
async sendVerification() {
|
||||
this.clearErrors()
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(this.email)) {
|
||||
this.emailError = '邮箱格式不正确'
|
||||
}
|
||||
if (!this.password || this.password.length < 6) {
|
||||
this.passwordError = '密码至少6位'
|
||||
}
|
||||
if (!this.username) {
|
||||
this.usernameError = '用户名不能为空'
|
||||
}
|
||||
if (this.emailError || this.passwordError || this.usernameError) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isWaitingForEmailSent = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: this.username,
|
||||
email: this.email,
|
||||
password: this.password
|
||||
})
|
||||
})
|
||||
this.isWaitingForEmailSent = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
this.emailStep = 1
|
||||
toast.success('验证码已发送,请查看邮箱')
|
||||
} else if (data.field) {
|
||||
if (data.field === 'username') this.usernameError = data.error
|
||||
if (data.field === 'email') this.emailError = data.error
|
||||
if (data.field === 'password') this.passwordError = data.error
|
||||
} else {
|
||||
toast.error(data.error || '发送失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('发送失败')
|
||||
}
|
||||
},
|
||||
async verifyCode() {
|
||||
try {
|
||||
this.isWaitingForEmailVerified = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: this.code,
|
||||
username: this.username
|
||||
})
|
||||
})
|
||||
this.isWaitingForEmailVerified = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (this.registerMode === 'WHITELIST') {
|
||||
this.$router.push('/signup-reason?token=' + data.token)
|
||||
} else {
|
||||
toast.success('注册成功,请登录')
|
||||
this.$router.push('/login')
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '注册失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('注册失败')
|
||||
}
|
||||
},
|
||||
signupWithGithub() {
|
||||
githubAuthorize()
|
||||
},
|
||||
signupWithDiscord() {
|
||||
discordAuthorize()
|
||||
},
|
||||
signupWithTwitter() {
|
||||
twitterAuthorize()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signup-page {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.signup-page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(40% - 120px);
|
||||
border-right: 1px solid var(--normal-border-color);
|
||||
padding-right: 120px;
|
||||
}
|
||||
|
||||
.signup-page-header-title {
|
||||
font-family: 'Pacifico', 'Comic Sans MS', cursive, 'Roboto', sans-serif;
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.signup-page-header {
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.email-signup-page-content {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.signup-page-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;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signup-page-input-icon {
|
||||
opacity: 0.5;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.signup-page-input-text {
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.other-signup-page-content {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.signup-page-button-primary {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(100% - 40px);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-page-button-primary.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.signup-page-button-primary.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.signup-page-button-primary:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.signup-page-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-page-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.signup-page-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.signup-page-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.signup-page-button-secondary {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.signup-page-button-secondary-link {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
width: calc(100% - 40px);
|
||||
margin-top: -10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.signup-page {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.email-signup-page-content {
|
||||
margin-top: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.signup-page-content {
|
||||
margin-top: 20px;
|
||||
width: calc(100% - 40px);
|
||||
border-right: none;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.signup-page-button-primary {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.signup-page-button-secondary {
|
||||
margin-top: 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.other-signup-page-content {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-page-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
26
frontend_nuxt/pages/twitter-callback.vue
Normal file
26
frontend_nuxt/pages/twitter-callback.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CallbackPage from '../components/CallbackPage.vue'
|
||||
import { twitterExchange } from '../utils/twitter'
|
||||
|
||||
export default {
|
||||
name: 'TwitterCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await twitterExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
this.$router.push('/signup-reason?token=' + result.token)
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
819
frontend_nuxt/pages/users/[id].vue
Normal file
819
frontend_nuxt/pages/users/[id].vue
Normal file
@@ -0,0 +1,819 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div v-if="isLoading" class="loading-page">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="profile-page-header">
|
||||
<div class="profile-page-header-avatar">
|
||||
<img :src="user.avatar" alt="avatar" class="profile-page-header-avatar-img" />
|
||||
</div>
|
||||
<div class="profile-page-header-user-info">
|
||||
<div class="profile-page-header-user-info-name">{{ user.username }}</div>
|
||||
<div class="profile-page-header-user-info-description">{{ user.introduction }}</div>
|
||||
<div v-if="!isMine && !subscribed" class="profile-page-header-subscribe-button" @click="subscribeUser">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
关注
|
||||
</div>
|
||||
<div v-if="!isMine && subscribed" class="profile-page-header-unsubscribe-button" @click="unsubscribeUser">
|
||||
<i class="fas fa-user-minus"></i>
|
||||
取消关注
|
||||
</div>
|
||||
<LevelProgress
|
||||
:exp="levelInfo.exp"
|
||||
:current-level="levelInfo.currentLevel"
|
||||
:next-exp="levelInfo.nextExp"
|
||||
/>
|
||||
<div class="profile-level-target">
|
||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||
<i
|
||||
class="fas fa-info-circle profile-exp-info"
|
||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-info">
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">加入时间:</div>
|
||||
<div class="profile-info-item-value">{{ formatDate(user.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后发帖时间:</div>
|
||||
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后评论时间:</div>
|
||||
<div class="profile-info-item-value">{{ user.lastCommentTime!=null?formatDate(user.lastCommentTime):"暂无评论" }}</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">浏览量:</div>
|
||||
<div class="profile-info-item-value">{{ user.totalViews }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-tabs">
|
||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'summary' }]" @click="selectedTab = 'summary'">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<div class="profile-tabs-item-label">总结</div>
|
||||
</div>
|
||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]" @click="selectedTab = 'timeline'">
|
||||
<i class="fas fa-clock"></i>
|
||||
<div class="profile-tabs-item-label">时间线</div>
|
||||
</div>
|
||||
<div :class="['profile-tabs-item', { selected: selectedTab === 'following' }]"
|
||||
@click="selectedTab = 'following'">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<div class="profile-tabs-item-label">关注</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tabLoading" class="tab-loading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="selectedTab === 'summary'" class="profile-summary">
|
||||
<div class="total-summary">
|
||||
<div class="summary-title">统计信息</div>
|
||||
<div class="total-summary-content">
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">访问天数</div>
|
||||
<div class="total-summary-item-value">{{ user.visitedDays }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已读帖子</div>
|
||||
<div class="total-summary-item-value">{{ user.readPosts }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已送出的💗</div>
|
||||
<div class="total-summary-item-value">{{ user.likesSent }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已收到的💗</div>
|
||||
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-divider">
|
||||
<div class="hot-reply">
|
||||
<div class="summary-title">热门回复</div>
|
||||
<div class="summary-content" v-if="hotReplies.length > 0">
|
||||
<BaseTimeline :items="hotReplies">
|
||||
<template #item="{ item }">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
<template v-if="item.comment.parentComment">
|
||||
下对
|
||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link">
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</router-link>
|
||||
回复了
|
||||
</template>
|
||||
<template v-else>
|
||||
下评论了
|
||||
</template>
|
||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link">
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.comment.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无热门回复</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hot-topic">
|
||||
<div class="summary-title">热门话题</div>
|
||||
<div class="summary-content" v-if="hotPosts.length > 0">
|
||||
<BaseTimeline :items="hotPosts">
|
||||
<template #item="{ item }">
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</router-link>
|
||||
<div class="timeline-snippet">
|
||||
{{ stripMarkdown(item.post.snippet) }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.post.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无热门话题</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hot-tag">
|
||||
<div class="summary-title">TA创建的tag</div>
|
||||
<div class="summary-content" v-if="hotTags.length > 0">
|
||||
<BaseTimeline :items="hotTags">
|
||||
<template #item="{ item }">
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.tag.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无标签</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||
<BasePlaceholder v-if="timelineItems.length === 0" text="暂无时间线" icon="fas fa-inbox" />
|
||||
<BaseTimeline :items="timelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下评论了
|
||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`" class="timeline-link">
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下对
|
||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link">
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</router-link>
|
||||
回复了
|
||||
<router-link :to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`" class="timeline-link">
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
|
||||
<div v-else class="follow-container">
|
||||
<div class="follow-tabs">
|
||||
<div :class="['follow-tab-item', { selected: followTab === 'followers' }]"
|
||||
@click="followTab = 'followers'">关注者
|
||||
</div>
|
||||
<div :class="['follow-tab-item', { selected: followTab === 'following' }]"
|
||||
@click="followTab = 'following'">正在关注
|
||||
</div>
|
||||
</div>
|
||||
<div class="follow-list">
|
||||
<UserList v-if="followTab === 'followers'" :users="followers" />
|
||||
<UserList v-else :users="followings" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, authState } from '../utils/auth'
|
||||
import BaseTimeline from '../components/BaseTimeline.vue'
|
||||
import UserList from '../components/UserList.vue'
|
||||
import BasePlaceholder from '../components/BasePlaceholder.vue'
|
||||
import LevelProgress from '../components/LevelProgress.vue'
|
||||
import { stripMarkdown, stripMarkdownLength } from '../utils/markdown'
|
||||
import TimeManager from '../utils/time'
|
||||
import { prevLevelExp } from '../utils/level'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'ProfileView',
|
||||
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress },
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const username = route.params.id
|
||||
|
||||
const user = ref({})
|
||||
const hotPosts = ref([])
|
||||
const hotReplies = ref([])
|
||||
const hotTags = ref([])
|
||||
const timelineItems = ref([])
|
||||
const followers = ref([])
|
||||
const followings = ref([])
|
||||
const subscribed = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const tabLoading = ref(false)
|
||||
const selectedTab = ref('summary')
|
||||
const followTab = ref('followers')
|
||||
|
||||
const levelInfo = computed(() => {
|
||||
const exp = user.value.experience || 0
|
||||
const currentLevel = user.value.currentLevel || 0
|
||||
const nextExp = user.value.nextLevelExp || 0
|
||||
const prevExp = prevLevelExp(currentLevel)
|
||||
const total = nextExp - prevExp
|
||||
const ratio = total > 0 ? (exp - prevExp) / total : 1
|
||||
const percent = Math.max(0, Math.min(1, ratio)) * 100
|
||||
return { exp, currentLevel, nextExp, percent }
|
||||
})
|
||||
|
||||
const isMine = computed(() => authState.username === username)
|
||||
|
||||
const formatDate = (d) => {
|
||||
if (!d) return ''
|
||||
return TimeManager.format(d)
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
const token = getToken()
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}`, { headers })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
user.value = data
|
||||
subscribed.value = !!data.subscribed
|
||||
} else if (res.status === 404) {
|
||||
router.replace('/404')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSummary = async () => {
|
||||
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
|
||||
if (postsRes.ok) {
|
||||
const data = await postsRes.json()
|
||||
hotPosts.value = data.map(p => ({ icon: 'fas fa-book', post: p }))
|
||||
}
|
||||
|
||||
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
|
||||
if (repliesRes.ok) {
|
||||
const data = await repliesRes.json()
|
||||
hotReplies.value = data.map(c => ({ icon: 'fas fa-comment', comment: c }))
|
||||
}
|
||||
|
||||
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
|
||||
if (tagsRes.ok) {
|
||||
const data = await tagsRes.json()
|
||||
hotTags.value = data.map(t => ({ icon: 'fas fa-tag', tag: t }))
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTimeline = async () => {
|
||||
const [postsRes, repliesRes, tagsRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`),
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`),
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/tags?limit=50`)
|
||||
])
|
||||
const posts = postsRes.ok ? await postsRes.json() : []
|
||||
const replies = repliesRes.ok ? await repliesRes.json() : []
|
||||
const tags = tagsRes.ok ? await tagsRes.json() : []
|
||||
const mapped = [
|
||||
...posts.map(p => ({
|
||||
type: 'post',
|
||||
icon: 'fas fa-book',
|
||||
post: p,
|
||||
createdAt: p.createdAt
|
||||
})),
|
||||
...replies.map(r => ({
|
||||
type: r.parentComment ? 'reply' : 'comment',
|
||||
icon: 'fas fa-comment',
|
||||
comment: r,
|
||||
createdAt: r.createdAt
|
||||
})),
|
||||
...tags.map(t => ({
|
||||
type: 'tag',
|
||||
icon: 'fas fa-tag',
|
||||
tag: t,
|
||||
createdAt: t.createdAt
|
||||
}))
|
||||
]
|
||||
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
timelineItems.value = mapped
|
||||
}
|
||||
|
||||
const fetchFollowUsers = async () => {
|
||||
const [followerRes, followingRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/followers`),
|
||||
fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
||||
])
|
||||
followers.value = followerRes.ok ? await followerRes.json() : []
|
||||
followings.value = followingRes.ok ? await followingRes.json() : []
|
||||
}
|
||||
|
||||
const loadSummary = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchSummary()
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadTimeline = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchTimeline()
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadFollow = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchFollowUsers()
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const subscribeUser = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
subscribed.value = true
|
||||
toast.success('已关注')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeUser = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/subscriptions/users/${username}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
subscribed.value = false
|
||||
toast.success('已取消关注')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const gotoTag = tag => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await fetchUser()
|
||||
await loadSummary()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
|
||||
watch(selectedTab, async val => {
|
||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||
await loadFollow()
|
||||
}
|
||||
})
|
||||
return {
|
||||
user,
|
||||
hotPosts,
|
||||
hotReplies,
|
||||
timelineItems,
|
||||
followers,
|
||||
followings,
|
||||
subscribed,
|
||||
isMine,
|
||||
isLoading,
|
||||
tabLoading,
|
||||
selectedTab,
|
||||
followTab,
|
||||
formatDate,
|
||||
stripMarkdown,
|
||||
stripMarkdownLength,
|
||||
loadTimeline,
|
||||
loadFollow,
|
||||
loadSummary,
|
||||
subscribeUser,
|
||||
unsubscribeUser,
|
||||
gotoTag,
|
||||
hotTags,
|
||||
levelInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.profile-page {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.profile-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.profile-page-header-avatar-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-page-header-user-info {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.profile-page-header-user-info-name {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.profile-page-header-user-info-description {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.profile-page-header-subscribe-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
color: white;
|
||||
background-color: var(--primary-color);
|
||||
margin-top: 15px;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-page-header-subscribe-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.profile-page-header-unsubscribe-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
color: var(--primary-color);
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
margin-top: 15px;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-level-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-level-current {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.profile-level-bar {
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
background-color: var(--normal-background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-level-bar-inner {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.profile-level-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-level-exp,
|
||||
.profile-level-target {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profile-exp-info {
|
||||
margin-left: 4px;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 20px;
|
||||
gap: 20px;
|
||||
border-top: 1px solid var(--normal-border-color);
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
scrollbar-width: none;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.profile-info-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-info-item-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.profile-info-item-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + 1px);
|
||||
z-index: 200;
|
||||
background-color: var(--background-color-blur);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
scrollbar-width: none;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-tabs-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.total-summary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.total-summary-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0px;
|
||||
column-gap: 20px;
|
||||
}
|
||||
|
||||
.total-summary-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.total-summary-item-label {
|
||||
font-size: 18px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.total-summary-item-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hot-reply,
|
||||
.hot-topic,
|
||||
.hot-tag {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.profile-timeline {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.timeline-snippet {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.timeline-link {
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.timeline-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.summary-empty {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tab-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.follow-container {}
|
||||
|
||||
.follow-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.follow-tab-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.follow-tab-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.follow-list {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.profile-page-header-avatar-img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hot-reply,
|
||||
.hot-topic,
|
||||
.hot-tag {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-timeline {
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
frontend_nuxt/router/index.js
Normal file
7
frontend_nuxt/router/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
push(path) {
|
||||
if (process.client) {
|
||||
window.location.href = path
|
||||
}
|
||||
}
|
||||
}
|
||||
7
frontend_nuxt/utils/clearVditorStorage.js
Normal file
7
frontend_nuxt/utils/clearVditorStorage.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export function clearVditorStorage() {
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('vditoreditor-') || key === 'vditor') {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
62
frontend_nuxt/utils/discord.js
Normal file
62
frontend_nuxt/utils/discord.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function discordAuthorize(state = '') {
|
||||
if (!DISCORD_CLIENT_ID) {
|
||||
toast.error('Discord 登录不可用')
|
||||
return
|
||||
}
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
||||
const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function discordExchange(code, state, reason) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirectUri: `${window.location.origin}/discord-callback`, reason, state })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
return {
|
||||
success: true,
|
||||
needReason: false
|
||||
}
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return {
|
||||
success: false,
|
||||
needReason: true,
|
||||
token: data.token
|
||||
}
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return {
|
||||
success: true,
|
||||
needReason: false
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: data.error || '登录失败'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('登录失败')
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: '登录失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
62
frontend_nuxt/utils/github.js
Normal file
62
frontend_nuxt/utils/github.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function githubAuthorize(state = '') {
|
||||
if (!GITHUB_CLIENT_ID) {
|
||||
toast.error('GitHub 登录不可用')
|
||||
return
|
||||
}
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
||||
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function githubExchange(code, state, reason) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirectUri: `${window.location.origin}/github-callback`, reason, state })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
return {
|
||||
success: true,
|
||||
needReason: false
|
||||
}
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return {
|
||||
success: false,
|
||||
needReason: true,
|
||||
token: data.token
|
||||
}
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return {
|
||||
success: true,
|
||||
needReason: false
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: data.error || '登录失败'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('登录失败')
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: '登录失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
79
frontend_nuxt/utils/google.js
Normal file
79
frontend_nuxt/utils/google.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
|
||||
export async function googleGetIdToken() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.google || !GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
reject()
|
||||
return
|
||||
}
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
callback: ({ credential }) => resolve(credential),
|
||||
use_fedcm: true
|
||||
})
|
||||
window.google.accounts.id.prompt()
|
||||
})
|
||||
}
|
||||
|
||||
export function googleAuthorize() {
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
return
|
||||
}
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
||||
const nonce = Math.random().toString(36).substring(2)
|
||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idToken })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
if (redirect_success) redirect_success()
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
if (redirect_not_approved) redirect_not_approved(data.token)
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
if (redirect_success) redirect_success()
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
export async function googleSignIn(redirect_success, redirect_not_approved) {
|
||||
try {
|
||||
const token = await googleGetIdToken()
|
||||
await googleAuthWithToken(token, redirect_success, redirect_not_approved)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
import router from '../router'
|
||||
|
||||
export function loginWithGoogle() {
|
||||
googleSignIn(
|
||||
() => {
|
||||
router.push('/')
|
||||
},
|
||||
token => {
|
||||
router.push('/signup-reason?token=' + token)
|
||||
}
|
||||
)
|
||||
}
|
||||
7
frontend_nuxt/utils/level.js
Normal file
7
frontend_nuxt/utils/level.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const LEVEL_EXP = [100, 200, 300, 600, 1200, 10000]
|
||||
|
||||
export const prevLevelExp = level => {
|
||||
if (level <= 0) return 0
|
||||
if (level - 1 < LEVEL_EXP.length) return LEVEL_EXP[level - 1]
|
||||
return LEVEL_EXP[LEVEL_EXP.length - 1]
|
||||
}
|
||||
@@ -1,6 +1,54 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
|
||||
function mentionPlugin(md) {
|
||||
const mentionReg = /^@\[([^\]]+)\]/
|
||||
function mention(state, silent) {
|
||||
const pos = state.pos
|
||||
if (state.src.charCodeAt(pos) !== 0x40) return false
|
||||
const match = mentionReg.exec(state.src.slice(pos))
|
||||
if (!match) return false
|
||||
if (!silent) {
|
||||
const tokenOpen = state.push('link_open', 'a', 1)
|
||||
tokenOpen.attrs = [
|
||||
['href', `/users/${match[1]}`],
|
||||
['target', '_blank'],
|
||||
['class', 'mention-link']
|
||||
]
|
||||
const text = state.push('text', '', 0)
|
||||
text.content = `@${match[1]}`
|
||||
state.push('link_close', 'a', -1)
|
||||
}
|
||||
state.pos += match[0].length
|
||||
return true
|
||||
}
|
||||
md.inline.ruler.before('emphasis', 'mention', mention)
|
||||
}
|
||||
|
||||
function tiebaEmojiPlugin(md) {
|
||||
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
|
||||
const name = tokens[idx].content
|
||||
const file = tiebaEmoji[name]
|
||||
return `<img class="emoji" src="${file}" alt="${name}">`
|
||||
}
|
||||
md.inline.ruler.before('emphasis', 'tieba-emoji', (state, silent) => {
|
||||
const pos = state.pos
|
||||
if (state.src.charCodeAt(pos) !== 0x3a) return false
|
||||
const match = state.src.slice(pos).match(/^:tieba(\d+):/)
|
||||
if (!match) return false
|
||||
const key = `tieba${match[1]}`
|
||||
if (!tiebaEmoji[key]) return false
|
||||
if (!silent) {
|
||||
const token = state.push('tieba-emoji', '', 0)
|
||||
token.content = key
|
||||
}
|
||||
state.pos += match[0].length
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
@@ -17,10 +65,30 @@ const md = new MarkdownIt({
|
||||
}
|
||||
})
|
||||
|
||||
// todo: 简单用正则操作一下,后续体验不佳可以采用 striptags
|
||||
md.use(mentionPlugin)
|
||||
md.use(tiebaEmojiPlugin)
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
return md.render(text || '')
|
||||
}
|
||||
|
||||
export function handleMarkdownClick(e) {
|
||||
if (e.target.classList.contains('copy-code-btn')) {
|
||||
const pre = e.target.closest('pre')
|
||||
const codeEl = pre && pre.querySelector('code')
|
||||
if (codeEl) {
|
||||
navigator.clipboard.writeText(codeEl.innerText).then(() => {
|
||||
toast.success('已复制')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stripMarkdown(text) {
|
||||
const html = md.render(text)
|
||||
return html.replace(/<\/?[^>]+>/g, '')
|
||||
const html = md.render(text || '')
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = html
|
||||
return el.textContent || el.innerText || ''
|
||||
}
|
||||
|
||||
export function stripMarkdownLength(text, length) {
|
||||
@@ -28,5 +96,6 @@ export function stripMarkdownLength(text, length) {
|
||||
if (!length || plain.length <= length) {
|
||||
return plain
|
||||
}
|
||||
// 截断并加省略号
|
||||
return plain.slice(0, length) + '...'
|
||||
}
|
||||
|
||||
48
frontend_nuxt/utils/push.js
Normal file
48
frontend_nuxt/utils/push.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { getToken } from './auth'
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (const b of bytes) binary += String.fromCharCode(b)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export async function registerPush() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/notifications-sw.js')
|
||||
const res = await fetch(`${API_BASE_URL}/api/push/public-key`)
|
||||
if (!res.ok) return
|
||||
const { key } = await res.json()
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key)
|
||||
})
|
||||
await fetch(`${API_BASE_URL}/api/push/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: arrayBufferToBase64(sub.getKey('p256dh')),
|
||||
auth: arrayBufferToBase64(sub.getKey('auth'))
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
25
frontend_nuxt/utils/reactions.js
Normal file
25
frontend_nuxt/utils/reactions.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export const reactionEmojiMap = {
|
||||
LIKE: '❤️',
|
||||
DISLIKE: '👎',
|
||||
RECOMMEND: '👏',
|
||||
ANGRY: '😡',
|
||||
FLUSHED: '😳',
|
||||
STAR_STRUCK: '🤩',
|
||||
ROFL: '🤣',
|
||||
HOLDING_BACK_TEARS: '🥹',
|
||||
MIND_BLOWN: '🤯',
|
||||
POOP: '💩',
|
||||
CLOWN: '🤡',
|
||||
SKULL: '☠️',
|
||||
FIRE: '🔥',
|
||||
EYES: '👀',
|
||||
FROWN: '☹️',
|
||||
HOT: '🥵',
|
||||
EAGLE: '🦅',
|
||||
SPIDER: '🕷️',
|
||||
BAT: '🦇',
|
||||
CHINA: '🇨🇳',
|
||||
USA: '🇺🇸',
|
||||
JAPAN: '🇯🇵',
|
||||
KOREA: '🇰🇷'
|
||||
}
|
||||
11
frontend_nuxt/utils/tiebaEmoji.js
Normal file
11
frontend_nuxt/utils/tiebaEmoji.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export const TIEBA_EMOJI_CDN = 'https://cdn.jsdelivr.net/gh/microlong666/tieba_mobile_emotions@master/'
|
||||
// export const TIEBA_EMOJI_CDN = 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor/dist/images/emoji/'
|
||||
|
||||
export const tiebaEmoji = (() => {
|
||||
const map = { tieba1: TIEBA_EMOJI_CDN + 'image_emoticon.png' }
|
||||
for (let i = 2; i <= 124; i++) {
|
||||
if (i > 50 && i < 62) continue
|
||||
map[`tieba${i}`] = TIEBA_EMOJI_CDN + `image_emoticon${i}.png`
|
||||
}
|
||||
return map
|
||||
})()
|
||||
79
frontend_nuxt/utils/twitter.js
Normal file
79
frontend_nuxt/utils/twitter.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
function generateCodeVerifier() {
|
||||
const array = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(array)
|
||||
return Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(codeVerifier) {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(codeVerifier)
|
||||
const digest = await window.crypto.subtle.digest('SHA-256', data)
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export async function twitterAuthorize(state = '') {
|
||||
if (!TWITTER_CLIENT_ID) {
|
||||
toast.error('Twitter 登录不可用')
|
||||
return
|
||||
}
|
||||
if (state === '') {
|
||||
state = Math.random().toString(36).substring(2, 15)
|
||||
}
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
const url =
|
||||
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` +
|
||||
`&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function twitterExchange(code, state, reason) {
|
||||
try {
|
||||
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
||||
sessionStorage.removeItem('twitter_code_verifier')
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/twitter-callback`,
|
||||
reason,
|
||||
state,
|
||||
codeVerifier
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
return { success: true, needReason: false }
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return { success: false, needReason: true, token: data.token }
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return { success: true, needReason: false }
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('登录失败')
|
||||
return { success: false, needReason: false, error: '登录失败' }
|
||||
}
|
||||
}
|
||||
30
frontend_nuxt/utils/user.js
Normal file
30
frontend_nuxt/utils/user.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { API_BASE_URL } from '../main'
|
||||
|
||||
export async function fetchFollowings(username) {
|
||||
if (!username) return []
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
||||
return res.ok ? await res.json() : []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
|
||||
return res.ok ? await res.json() : []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchUsers(keyword) {
|
||||
if (!keyword) return []
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`)
|
||||
return res.ok ? await res.json() : []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
176
frontend_nuxt/utils/vditor.js
Normal file
176
frontend_nuxt/utils/vditor.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import Vditor from 'vditor'
|
||||
import 'vditor/dist/index.css'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { getToken, authState } from './auth'
|
||||
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
|
||||
export function getEditorTheme() {
|
||||
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
|
||||
}
|
||||
|
||||
export function getPreviewTheme() {
|
||||
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function createVditor(editorId, options = {}) {
|
||||
const {
|
||||
placeholder = '',
|
||||
preview = {},
|
||||
input,
|
||||
after
|
||||
} = options
|
||||
|
||||
const fetchMentions = async (value) => {
|
||||
if (!value) {
|
||||
const [followings, admins] = await Promise.all([
|
||||
fetchFollowings(authState.username),
|
||||
fetchAdmins()
|
||||
])
|
||||
const combined = [...followings, ...admins]
|
||||
const seen = new Set()
|
||||
return combined.filter(u => {
|
||||
if (seen.has(u.id)) return false
|
||||
seen.add(u.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return searchUsers(value)
|
||||
}
|
||||
|
||||
const isMobile = window.innerWidth <= 768
|
||||
const toolbar = isMobile
|
||||
? ['emoji', 'upload']
|
||||
: [
|
||||
'emoji',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'|',
|
||||
'list',
|
||||
'line',
|
||||
'quote',
|
||||
'code',
|
||||
'inline-code',
|
||||
'|',
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'link',
|
||||
'upload'
|
||||
]
|
||||
|
||||
let vditor
|
||||
vditor = new Vditor(editorId, {
|
||||
placeholder,
|
||||
height: 'auto',
|
||||
theme: getEditorTheme(),
|
||||
preview: Object.assign({
|
||||
theme: { current: getPreviewTheme() },
|
||||
}, preview),
|
||||
hint: {
|
||||
emoji: tiebaEmoji,
|
||||
extend: [
|
||||
{
|
||||
key: '@',
|
||||
hint: async (key) => {
|
||||
const list = await fetchMentions(key)
|
||||
return list.map(u => ({
|
||||
value: `@[${u.username}]`,
|
||||
html: `<img src="${u.avatar}" /> @${u.username}`
|
||||
}))
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
|
||||
toolbar,
|
||||
upload: {
|
||||
accept: 'image/*,video/*',
|
||||
multiple: false,
|
||||
handler: async (files) => {
|
||||
const file = files[0]
|
||||
vditor.tip('图片上传中', 0)
|
||||
vditor.disabled()
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`,
|
||||
{ headers: { Authorization: `Bearer ${getToken()}` } }
|
||||
)
|
||||
if (!res.ok) {
|
||||
vditor.enable()
|
||||
vditor.tip('获取上传地址失败')
|
||||
return '获取上传地址失败'
|
||||
}
|
||||
const info = await res.json()
|
||||
const put = await fetch(info.uploadUrl, { method: 'PUT', body: file })
|
||||
if (!put.ok) {
|
||||
vditor.enable()
|
||||
vditor.tip('上传失败')
|
||||
return '上传失败'
|
||||
}
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase()
|
||||
const imageExts = [
|
||||
'apng',
|
||||
'bmp',
|
||||
'gif',
|
||||
'ico',
|
||||
'cur',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'jfif',
|
||||
'pjp',
|
||||
'pjpeg',
|
||||
'png',
|
||||
'svg',
|
||||
'webp'
|
||||
]
|
||||
const audioExts = ['wav', 'mp3', 'ogg']
|
||||
let md
|
||||
if (imageExts.includes(ext)) {
|
||||
md = ``
|
||||
} else if (audioExts.includes(ext)) {
|
||||
md = `<audio controls="controls" src="${info.fileUrl}"></audio>`
|
||||
} else {
|
||||
md = `[${file.name}](${info.fileUrl})`
|
||||
}
|
||||
vditor.insertValue(md + '\n')
|
||||
vditor.enable()
|
||||
vditor.tip('上传成功')
|
||||
return null
|
||||
}
|
||||
},
|
||||
// upload: {
|
||||
// fieldName: 'file',
|
||||
// url: `${API_BASE_URL}/api/upload`,
|
||||
// accept: 'image/*,video/*',
|
||||
// multiple: false,
|
||||
// headers: { Authorization: `Bearer ${getToken()}` },
|
||||
// format(files, responseText) {
|
||||
// const res = JSON.parse(responseText)
|
||||
// if (res.code === 0) {
|
||||
// return JSON.stringify({
|
||||
// code: 0,
|
||||
// msg: '',
|
||||
// data: {
|
||||
// errFiles: [],
|
||||
// succMap: { [files[0].name]: res.data.url }
|
||||
// }
|
||||
// })
|
||||
// } else {
|
||||
// return JSON.stringify({
|
||||
// code: 1,
|
||||
// msg: '上传失败',
|
||||
// data: { errFiles: files.map(f => f.name), succMap: {} }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
toolbarConfig: { pin: true },
|
||||
cache: { enable: false },
|
||||
input,
|
||||
after
|
||||
})
|
||||
|
||||
return vditor
|
||||
}
|
||||
Reference in New Issue
Block a user