优化目录结构

This commit is contained in:
WilliamColton
2025-08-03 01:27:28 +08:00
parent d63081955e
commit c08723574d
222 changed files with 2 additions and 25 deletions

View File

@@ -0,0 +1,126 @@
<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/about.md' },
{ name: 'agreement', label: '用户协议', file: '/about/agreement.md' },
{ name: 'guideline', label: '创作准则', file: '/about/guideline.md' },
{ name: 'privacy', label: '隐私政策', file: '/about/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;
height: calc(100vh - var(--header-height));
padding-top: var(--header-height);
overflow-y: auto;
}
.about-tabs {
position: sticky;
top: 1px;
z-index: 200;
background-color: var(--background-color-blur);
backdrop-filter: blur(10px);
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>

View File

@@ -0,0 +1,170 @@
<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(100vh - var(--header-height) - 40px);
overflow-y: auto;
padding-top: calc(var(--header-height) + 20px);
}
.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>

View File

@@ -0,0 +1,48 @@
<template>
<div class="discord-callback-page">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<div class="discord-callback-page-text">Magic is happening...</div>
</div>
</template>
<script>
import { discordExchange } from '../utils/discord'
import { hatch } from 'ldrs'
hatch.register()
export default {
name: 'DiscordCallbackPageView',
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>
<style scoped>
.discord-callback-page {
background-color: var(--background-color);
height: calc(100vh - var(--header-height));
padding-top: var(--header-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.discord-callback-page-text {
margin-top: 25px;
font-size: 16px;
color: var(--primary-color);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,340 @@
<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: calc(100vh - var(--header-height));
padding-top: var(--header-height);
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>

View File

@@ -0,0 +1,176 @@
<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: calc(100vh - var(--header-height));
padding-top: var(--header-height);
}
.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>

View File

@@ -0,0 +1,48 @@
<template>
<div class="github-callback-page">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<div class="github-callback-page-text">Magic is happening...</div>
</div>
</template>
<script>
import { githubExchange } from '../utils/github'
import { hatch } from 'ldrs'
hatch.register()
export default {
name: 'GithubCallbackPageView',
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>
<style scoped>
.github-callback-page {
background-color: var(--background-color);
height: calc(100vh - var(--header-height));
padding-top: var(--header-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.github-callback-page-text {
margin-top: 25px;
font-size: 16px;
color: var(--primary-color);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,731 @@
<template>
<div class="home-page" @scroll="handleScroll">
<div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航</div>
<div class="search-subtitle">愿你在此遇见灵感与共鸣若有疑惑欢迎发问亦可在知识的海洋中搜寻答案</div>
<SearchDropdown />
</div>
<div class="topic-container">
<div class="topic-item-container">
<div
v-for="topic in topics"
:key="topic"
class="topic-item"
:class="{ selected: topic === selectedTopic }"
@click="selectedTopic = topic"
>
{{ topic }}
</div>
<div class="topic-select-container">
<CategorySelect v-model="selectedCategory" :options="categoryOptions" />
<TagSelect v-model="selectedTags" :options="tagOptions" />
</div>
</div>
</div>
<div class="article-container">
<template v-if="selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'">
<div class="article-header-container">
<div class="header-item main-item">
<div class="header-item-text">话题</div>
</div>
<div class="header-item avatars">
<div class="header-item-text">参与人员</div>
</div>
<div class="header-item comments">
<div class="header-item-text">回复</div>
</div>
<div class="header-item views">
<div class="header-item-text">浏览</div>
</div>
<div class="header-item activity">
<div class="header-item-text">活动</div>
</div>
</div>
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else-if="articles.length === 0">
<div class="no-posts-container">
<div class="no-posts-text">暂时没有帖子 :( 点击发帖发送第一篇相关帖子吧!</div>
</div>
</div>
<div class="article-item" v-for="article in articles" :key="article.id">
<div class="article-main-container">
<router-link class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
{{ article.title }}
</router-link>
<div class="article-item-description main-item">{{ sanitizeDescription(article.description) }}</div>
<div class="article-info-container main-item">
<ArticleCategory :category="article.category" />
<ArticleTags :tags="article.tags" />
</div>
</div>
<div class="article-member-avatars-container">
<router-link
v-for="member in article.members"
:key="member.id"
class="article-member-avatar-item"
:to="`/users/${member.id}`"
>
<img class="article-member-avatar-item-img" :src="member.avatar" alt="avatar" />
</router-link>
</div>
<div class="article-comments main-info-text">
{{ article.comments }}
</div>
<div class="article-views main-info-text">
{{ article.views }}
</div>
<div class="article-time main-info-text">
{{ article.time }}
</div>
</div>
</template>
<div v-else-if="selectedTopic === '热门'" class="placeholder-container">
热门帖子功能开发中敬请期待
</div>
<div v-else class="placeholder-container">
分类浏览功能开发中敬请期待
</div>
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { stripMarkdown } from '../utils/markdown'
import { API_BASE_URL } from '../main'
import { getToken } from '../utils/auth'
import TimeManager from '../utils/time'
import CategorySelect from '../components/CategorySelect.vue'
import TagSelect from '../components/TagSelect.vue'
import ArticleTags from '../components/ArticleTags.vue'
import ArticleCategory from '../components/ArticleCategory.vue'
import SearchDropdown from '../components/SearchDropdown.vue'
import { hatch } from 'ldrs'
import { isMobile } from '../utils/screen'
hatch.register()
export default {
name: 'HomePageView',
components: {
CategorySelect,
TagSelect,
ArticleTags,
ArticleCategory,
SearchDropdown
},
setup() {
const route = useRoute()
const selectedCategory = ref('')
if (route.query.category) {
const c = decodeURIComponent(route.query.category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTags = ref([])
if (route.query.tags) {
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
selectedTags.value = t
.split(',')
.filter(v => v)
.map(v => decodeURIComponent(v))
.map(v => (isNaN(v) ? v : Number(v)))
}
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking'
? '排行榜'
: route.query.view === 'latest'
? '最新'
: '最新回复'
)
const articles = ref([])
const page = ref(0)
const pageSize = 10
const allLoaded = ref(false)
const countComments = (list) =>
list.reduce((sum, c) => sum + 1 + countComments(c.replies || []), 0)
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
if (res.ok) {
categoryOptions.value = [await res.json()]
}
} catch (e) { /* ignore */ }
}
if (selectedTags.value.length) {
const arr = []
for (const t of selectedTags.value) {
if (!isNaN(t)) {
try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json())
} catch (e) { /* ignore */ }
}
}
tagOptions.value = arr
}
}
const buildUrl = () => {
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach(t => {
url += `&tagIds=${t}`
})
}
return url
}
const buildRankUrl = () => {
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach(t => {
url += `&tagIds=${t}`
})
}
return url
}
const buildReplyUrl = () => {
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach(t => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : ''
}
})
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map(p => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
comments: countComments(p.comments || []),
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt
}))
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchRanking = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const res = await fetch(buildRankUrl())
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map(p => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
comments: countComments(p.comments || []),
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt
}))
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchLatestReply = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const res = await fetch(buildReplyUrl())
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map(p => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map(m => ({ id: m.id, avatar: m.avatar })),
comments: countComments(p.comments || []),
views: p.views,
time: TimeManager.format(p.lastReplyAt || p.createdAt),
pinned: !!p.pinnedAt
}))
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchContent = async (reset = false) => {
if (selectedTopic.value === '排行榜') {
fetchRanking(reset)
} else if (selectedTopic.value === '最新回复') {
fetchLatestReply(reset)
} else {
fetchPosts(reset)
}
}
const handleScroll = (e) => {
const el = e.target
if (el.scrollHeight - el.scrollTop <= el.clientHeight + 50) {
fetchContent()
}
}
onMounted(async () => {
fetchContent()
await loadOptions()
})
watch([selectedCategory, selectedTags], () => {
fetchContent(true)
})
watch(selectedTopic, () => {
fetchContent(true)
})
const sanitizeDescription = (text) => stripMarkdown(text)
return { topics, selectedTopic, articles, sanitizeDescription, isLoadingPosts, handleScroll, selectedCategory, selectedTags, tagOptions, categoryOptions, isMobile }
}
}
</script>
<style scoped>
.home-page {
background-color: var(--background-color);
height: calc(100vh - var(--header-height));
padding-top: var(--header-height);
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
/* enable container queries */
container-type: inline-size;
container-name: home-page;
}
.search-container {
margin-top: 100px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.search-title {
font-size: 32px;
font-weight: bold;
}
.search-subtitle {
font-size: 16px;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.bottom-loading {
height: 100px;
}
.no-posts-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.no-posts-text {
font-size: 14px;
opacity: 0.7;
}
.topic-container {
position: sticky;
top: 1px;
z-index: 10;
background-color: var(--background-color-blur);
backdrop-filter: blur(10px);
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 0;
}
.topic-item-container {
margin-left: 20px;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-size: 16px;
flex-wrap: wrap;
}
.topic-select-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.topic-item {
padding: 2px 10px;
cursor: pointer;
}
.topic-item.selected {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
}
.article-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding-bottom: 100px;
}
.article-header-container {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
color: gray;
border-bottom: 1px solid var(--normal-border-color);
padding-bottom: 10px;
}
.article-item {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
border-bottom: 1px solid var(--normal-border-color);
}
.article-main-container,
.header-item.main-item {
width: calc(60% - 20px);
padding-left: 20px;
}
/* .article-member-avatars-container,
.header-item.avatars, */
.article-comments,
.header-item.comments,
.article-views,
.header-item.views,
.article-time,
.header-item.activity {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.article-member-avatars-container,
.header-item.avatars {
width: 20%;
}
.article-comments,
.header-item.comments {
width: 5%;
}
.article-views,
.header-item.views {
width: 5%;
}
.article-time,
.header-item.activity {
width: 10%;
}
.article-item-title {
margin-top: 10px;
font-size: 20px;
text-decoration: none;
color: var(--text-color);
max-width: 100%;
}
.article-item-title:hover {
color: var(--primary-color);
text-decoration: underline;
}
.pinned-icon {
margin-right: 4px;
color: var(--primary-color);
}
.article-item-description {
margin-top: 10px;
font-size: 14px;
color: gray;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.article-info-container {
margin-top: 10px;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.article-tags-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.article-tag-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.article-main-container {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.article-member-avatars-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 3px;
margin-left: 20px;
}
.article-member-avatar-item {
width: 25px;
height: 25px;
border-radius: 50%;
overflow: hidden;
}
.article-member-avatar-item-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder-container {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
font-size: 16px;
opacity: 0.7;
}
.main-info-text {
font-size: 14px;
opacity: 0.7;
}
@container home-page (max-width: 900px) {
.article-main-container,
.header-item.main-item {
width: calc(70% - 20px);
padding-left: 20px;
}
.article-member-avatars-container,
.header-item.avatars {
width: 10%;
}
.article-comments,
.header-item.comments {
width: 5%;
}
.article-views,
.header-item.views {
width: 5%;
}
.article-time,
.header-item.activity {
width: 10%;
}
.article-member-avatar-item:nth-child(n+4) {
display: none;
}
}
@container home-page (max-width: 768px) {
.article-main-container,
.header-item.main-item {
width: calc(70% - 20px);
padding-left: 20px;
}
.article-member-avatars-container,
.header-item.avatars {
width: 10%;
}
.article-comments,
.header-item.comments {
display: none;
}
.article-views,
.header-item.views {
display: none;
}
.article-time,
.header-item.activity {
width: 10%;
margin-right: 3%;
}
.article-header-container {
display: none;
}
.article-member-avatar-item:nth-child(n+2) {
display: none;
}
.header-item-text {
font-size: 12px;
}
.article-item-title {
font-size: 16px;
font-weight: bold;
}
.article-item-description {
margin-top: 2px;
font-size: 10px;
max-width: 100%;
}
.main-info-text {
font-size: 10px;
opacity: 0.5;
}
}
</style>

View File

@@ -0,0 +1,303 @@
<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="loginWithGoogle">
<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 { loginWithGoogle } 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 { loginWithGoogle }
},
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: calc(100vh - var(--header-height));
padding-top: var(--header-height);
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>

View File

@@ -0,0 +1,760 @@
<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 } from '../utils/notification'
import { toast } from '../main'
import { stripMarkdownLength } from '../utils/markdown'
import TimeManager from '../utils/time'
import { hatch } from 'ldrs'
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 ok = await markNotificationsRead([id])
if (ok) {
const n = notifications.value.find(n => n.id === id)
if (n) n.read = true
await fetchUnreadCount()
}
}
const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value.filter(n => n.type !== 'REGISTER_REQUEST').map(n => n.id)
const ok = await markNotificationsRead(idsToMark)
if (ok) {
notifications.value.forEach(n => {
if (n.type !== 'REGISTER_REQUEST') n.read = true
})
await 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 reactionEmojiMap = {
LIKE: '❤️',
DISLIKE: '👎',
RECOMMEND: '👏',
ANGRY: '😡',
FLUSHED: '😳',
STAR_STRUCK: '🤩',
ROFL: '🤣',
HOLDING_BACK_TEARS: '🥹',
MIND_BLOWN: '🤯',
POOP: '💩',
CLOWN: '🤡',
SKULL: '☠️'
}
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);
height: calc(100vh - var(--header-height));
padding-top: var(--header-height);
overflow-y: auto;
}
.message-page-header {
position: sticky;
top: 1px;
z-index: 200;
background-color: var(--background-color-blur);
backdrop-filter: blur(10px);
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;
}
.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>

View File

@@ -0,0 +1,390 @@
<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-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);
height: calc(100vh - var(--header-height));
padding-top: var(--header-height);
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-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>

View File

@@ -0,0 +1,34 @@
<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: calc(100vh - var(--header-height));
padding-top: var(--header-height);
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>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,816 @@
<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.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 = await fetch(`${API_BASE_URL}/api/users/${username}/posts?limit=50`)
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/replies?limit=50`)
const tagsRes = await 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 = await fetch(`${API_BASE_URL}/api/users/${username}/followers`)
const followingRes = await 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);
height: calc(100vh - var(--header-height));
padding-top: var(--header-height);
overflow-y: auto;
overflow-x: hidden;
}
.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: 1px;
z-index: 200;
background-color: var(--background-color-blur);
backdrop-filter: blur(10px);
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>

View File

@@ -0,0 +1,363 @@
<template>
<div class="settings-page">
<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 { hatch } from 'ldrs'
hatch.register()
export default {
name: 'SettingsPageView',
components: { BaseInput, Dropdown },
data() {
return {
username: '',
introduction: '',
usernameError: '',
avatar: '',
avatarFile: null,
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]
this.avatarFile = file
if (file) {
const reader = new FileReader()
reader.onload = () => {
this.avatar = reader.result
}
reader.readAsDataURL(file)
}
},
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(100vh - var(--header-height) - 80px);
padding-top: calc(var(--header-height) + 40px);
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>

View File

@@ -0,0 +1,413 @@
<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="loginWithGoogle">
<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 { loginWithGoogle } 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 { loginWithGoogle }
},
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: calc(100vh - var(--header-height));
padding-top: var(--header-height);
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>

View File

@@ -0,0 +1,143 @@
<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.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: calc(100vh - var(--header-height));
padding-top: var(--header-height);
}
.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>

View File

@@ -0,0 +1,54 @@
<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: calc(100vh - var(--header-height) - 40px);
padding-top: calc(var(--header-height) + 20px);
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="twitter-callback-page">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<div class="twitter-callback-page-text">Magic is happening...</div>
</div>
</template>
<script>
import { twitterExchange } from '../utils/twitter'
import { hatch } from 'ldrs'
hatch.register()
export default {
name: 'TwitterCallbackPageView',
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>
<style scoped>
.twitter-callback-page {
background-color: var(--background-color);
height: calc(100vh - var(--header-height));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: var(--header-height);
}
.twitter-callback-page-text {
margin-top: 25px;
font-size: 16px;
color: var(--primary-color);
font-weight: bold;
}
</style>