chore: migrate legacy pages and utilities to nuxt

This commit is contained in:
Tim
2025-08-07 20:21:22 +08:00
parent 73b9dcf0cd
commit 565678f79a
49 changed files with 7894 additions and 5 deletions
+142
View 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
View 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>
@@ -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
View 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
View 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
View 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
View 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>
@@ -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
View 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>
@@ -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>
@@ -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
View 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
View 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
View 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
View 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>