feat(app): add announcements, feedback, settings (stage 5)

This commit is contained in:
2025-12-14 00:27:05 +08:00
parent 54cf6fe538
commit 69443c2de6
20 changed files with 715 additions and 64 deletions

View File

@@ -0,0 +1,12 @@
import { publicApi } from './http'
export async function fetchActiveAnnouncement() {
const { data } = await publicApi.get('/announcements/active')
return data
}
export async function dismissAnnouncement(announcementId) {
const { data } = await publicApi.post(`/announcements/${announcementId}/dismiss`, {})
return data
}

View File

@@ -0,0 +1,12 @@
import { publicApi } from './http'
export async function submitFeedback(payload) {
const { data } = await publicApi.post('/feedback', payload)
return data
}
export async function fetchMyFeedbacks() {
const { data } = await publicApi.get('/feedback')
return data
}

View File

@@ -0,0 +1,32 @@
import { publicApi } from './http'
export async function fetchUserEmail() {
const { data } = await publicApi.get('/user/email')
return data
}
export async function bindEmail(payload) {
const { data } = await publicApi.post('/user/bind-email', payload)
return data
}
export async function unbindEmail() {
const { data } = await publicApi.post('/user/unbind-email', {})
return data
}
export async function fetchEmailNotify() {
const { data } = await publicApi.get('/user/email-notify')
return data
}
export async function updateEmailNotify(payload) {
const { data } = await publicApi.post('/user/email-notify', payload)
return data
}
export async function changePassword(payload) {
const { data } = await publicApi.post('/user/password', payload)
return data
}

View File

@@ -1,9 +1,19 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Calendar, Camera, User } from '@element-plus/icons-vue'
import { fetchActiveAnnouncement, dismissAnnouncement } from '../api/announcements'
import { fetchMyFeedbacks, submitFeedback } from '../api/feedback'
import {
bindEmail,
changePassword,
fetchEmailNotify,
fetchUserEmail,
unbindEmail,
updateEmailNotify,
} from '../api/settings'
import { useUserStore } from '../stores/user'
const route = useRoute()
@@ -14,6 +24,42 @@ const isMobile = ref(false)
const drawerOpen = ref(false)
let mediaQuery
const announcementOpen = ref(false)
const announcement = ref(null)
const announcementLoading = ref(false)
const feedbackOpen = ref(false)
const feedbackTab = ref('new')
const feedbackSubmitting = ref(false)
const feedbackLoading = ref(false)
const myFeedbacks = ref([])
const feedbackForm = reactive({
title: '',
description: '',
contact: '',
})
const settingsOpen = ref(false)
const settingsTab = ref('email')
const emailLoading = ref(false)
const bindEmailLoading = ref(false)
const emailInfo = reactive({
email: '',
email_verified: false,
})
const bindEmailValue = ref('')
const emailNotifyLoading = ref(false)
const emailNotifyEnabled = ref(true)
const passwordLoading = ref(false)
const passwordForm = reactive({
current_password: '',
new_password: '',
confirm_password: '',
})
function syncIsMobile() {
isMobile.value = Boolean(mediaQuery?.matches)
if (!isMobile.value) drawerOpen.value = false
@@ -27,6 +73,8 @@ onMounted(() => {
userStore.refreshVipInfo().catch(() => {
window.location.href = '/login'
})
loadAnnouncement()
})
onBeforeUnmount(() => {
@@ -60,6 +108,258 @@ async function logout() {
await userStore.logout()
window.location.href = '/login'
}
function openFeedbackForm() {
feedbackTab.value = 'new'
feedbackForm.title = ''
feedbackForm.description = ''
feedbackForm.contact = ''
feedbackOpen.value = true
}
async function openMyFeedbacks() {
feedbackTab.value = 'list'
feedbackOpen.value = true
await loadMyFeedbacks()
}
async function loadMyFeedbacks() {
feedbackLoading.value = true
try {
const list = await fetchMyFeedbacks()
myFeedbacks.value = Array.isArray(list) ? list : []
} catch {
myFeedbacks.value = []
} finally {
feedbackLoading.value = false
}
}
function feedbackStatusLabel(status) {
if (status === 'replied') return '已回复'
if (status === 'closed') return '已关闭'
return '待处理'
}
function feedbackStatusTagType(status) {
if (status === 'replied') return 'success'
if (status === 'closed') return 'info'
return 'warning'
}
async function submitFeedbackForm() {
const title = feedbackForm.title.trim()
const description = feedbackForm.description.trim()
const contact = feedbackForm.contact.trim()
if (!title || !description) {
ElMessage.error('标题和描述不能为空')
return
}
feedbackSubmitting.value = true
try {
const res = await submitFeedback({ title, description, contact })
ElMessage.success(res?.message || '反馈提交成功')
feedbackOpen.value = false
feedbackForm.title = ''
feedbackForm.description = ''
feedbackForm.contact = ''
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '提交失败')
} finally {
feedbackSubmitting.value = false
}
}
async function openSettings() {
settingsOpen.value = true
settingsTab.value = 'email'
await loadSettings()
}
async function loadSettings() {
await Promise.all([loadEmailInfo(), loadEmailNotify()])
}
async function loadEmailInfo() {
emailLoading.value = true
try {
const data = await fetchUserEmail()
emailInfo.email = data?.email || ''
emailInfo.email_verified = Boolean(data?.email_verified)
bindEmailValue.value = emailInfo.email || ''
} catch {
emailInfo.email = ''
emailInfo.email_verified = false
bindEmailValue.value = ''
} finally {
emailLoading.value = false
}
}
async function loadEmailNotify() {
emailNotifyLoading.value = true
try {
const data = await fetchEmailNotify()
emailNotifyEnabled.value = Boolean(data?.enabled)
} catch {
emailNotifyEnabled.value = true
} finally {
emailNotifyLoading.value = false
}
}
async function onBindEmail() {
const email = bindEmailValue.value.trim().toLowerCase()
if (!email) {
ElMessage.error('请输入邮箱地址')
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
ElMessage.error('邮箱格式不正确')
return
}
bindEmailLoading.value = true
try {
const res = await bindEmail({ email })
ElMessage.success(res?.message || '验证邮件已发送')
emailInfo.email = email
emailInfo.email_verified = false
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '绑定失败')
} finally {
bindEmailLoading.value = false
}
}
async function onUnbindEmail() {
try {
await ElMessageBox.confirm('确定要解绑当前邮箱吗?', '解绑邮箱', {
confirmButtonText: '解绑',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await unbindEmail()
if (res?.success) {
ElMessage.success(res?.message || '邮箱已解绑')
await loadEmailInfo()
return
}
ElMessage.error(res?.error || '解绑失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '解绑失败')
}
}
async function onToggleEmailNotify(value) {
const previous = emailNotifyEnabled.value
emailNotifyEnabled.value = Boolean(value)
emailNotifyLoading.value = true
try {
const res = await updateEmailNotify({ enabled: Boolean(value) })
if (res?.success) {
ElMessage.success('已更新')
return
}
emailNotifyEnabled.value = previous
ElMessage.error(res?.error || '更新失败')
} catch (e) {
emailNotifyEnabled.value = previous
const data = e?.response?.data
ElMessage.error(data?.error || '更新失败')
} finally {
emailNotifyLoading.value = false
}
}
async function onChangePassword() {
const currentPassword = passwordForm.current_password
const newPassword = passwordForm.new_password
const confirmPassword = passwordForm.confirm_password
if (!currentPassword || !newPassword || !confirmPassword) {
ElMessage.error('请填写完整信息')
return
}
if (String(newPassword).length < 6) {
ElMessage.error('新密码至少6位')
return
}
if (newPassword !== confirmPassword) {
ElMessage.error('两次输入的新密码不一致')
return
}
passwordLoading.value = true
try {
const res = await changePassword({ current_password: currentPassword, new_password: newPassword })
if (res?.success) {
ElMessage.success('密码修改成功')
passwordForm.current_password = ''
passwordForm.new_password = ''
passwordForm.confirm_password = ''
return
}
ElMessage.error(res?.error || '修改失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '修改失败')
} finally {
passwordLoading.value = false
}
}
async function loadAnnouncement() {
announcementLoading.value = true
try {
const data = await fetchActiveAnnouncement()
const ann = data?.announcement
if (!ann?.id) return
const sessionKey = `announcement_closed_${ann.id}`
if (window.sessionStorage.getItem(sessionKey) === '1') return
announcement.value = ann
announcementOpen.value = true
} catch {
// ignore
} finally {
announcementLoading.value = false
}
}
function closeAnnouncementOnce() {
const ann = announcement.value
if (ann?.id) window.sessionStorage.setItem(`announcement_closed_${ann.id}`, '1')
announcementOpen.value = false
}
async function dismissAnnouncementPermanently() {
const ann = announcement.value
if (!ann?.id) {
announcementOpen.value = false
return
}
try {
const res = await dismissAnnouncement(ann.id)
if (res?.success) ElMessage.success('已永久关闭')
} catch {
// ignore
} finally {
announcementOpen.value = false
}
}
</script>
<template>
@@ -96,6 +396,8 @@ async function logout() {
({{ userStore.vipDaysLeft }}天后到期)
</span>
</div>
<el-button text type="primary" @click="openFeedbackForm">反馈</el-button>
<el-button text @click="openSettings">设置</el-button>
<el-button type="primary" plain @click="logout">退出</el-button>
</div>
</el-header>
@@ -122,9 +424,191 @@ async function logout() {
</el-menu-item>
</el-menu>
<div class="drawer-actions">
<el-button text type="primary" style="width: 100%" @click="openFeedbackForm">问题反馈</el-button>
<el-button text style="width: 100%" @click="openSettings">个人设置</el-button>
<el-button type="primary" plain style="width: 100%" @click="logout">退出登录</el-button>
</div>
</el-drawer>
<el-dialog v-model="announcementOpen" width="min(560px, 92vw)" :title="announcement?.title || '系统公告'">
<div class="announcement-body" v-loading="announcementLoading">
<div class="announcement-content">{{ announcement?.content || '' }}</div>
</div>
<template #footer>
<el-button @click="closeAnnouncementOnce">当次关闭</el-button>
<el-button type="primary" @click="dismissAnnouncementPermanently">永久关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="feedbackOpen" title="问题反馈" width="min(720px, 92vw)">
<el-tabs v-model="feedbackTab" @tab-change="(name) => name === 'list' && loadMyFeedbacks()">
<el-tab-pane label="提交反馈" name="new">
<el-form label-position="top">
<el-form-item label="标题">
<el-input v-model="feedbackForm.title" placeholder="简要描述问题" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="详细描述">
<el-input v-model="feedbackForm.description" type="textarea" :rows="5" placeholder="请详细描述您遇到的问题" maxlength="2000" show-word-limit />
</el-form-item>
<el-form-item label="联系方式(可选)">
<el-input v-model="feedbackForm.contact" placeholder="方便我们联系您" />
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="我的反馈" name="list">
<el-skeleton v-if="feedbackLoading" :rows="6" animated />
<template v-else>
<el-empty v-if="myFeedbacks.length === 0" description="暂无反馈" />
<el-collapse v-else accordion>
<el-collapse-item v-for="item in myFeedbacks" :key="item.id" :name="String(item.id)">
<template #title>
<div class="feedback-title">
<span class="feedback-title-text">{{ item.title }}</span>
<el-tag size="small" effect="light" :type="feedbackStatusTagType(item.status)">
{{ feedbackStatusLabel(item.status) }}
</el-tag>
<span class="feedback-time app-muted">{{ item.created_at || '' }}</span>
</div>
</template>
<div class="feedback-body">
<div class="feedback-section">
<div class="feedback-label app-muted">描述</div>
<div class="feedback-text">{{ item.description }}</div>
</div>
<div v-if="item.admin_reply" class="feedback-section">
<div class="feedback-label app-muted">管理员回复</div>
<div class="feedback-text">{{ item.admin_reply }}</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</template>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="feedbackOpen = false">关闭</el-button>
<el-button v-if="feedbackTab === 'list'" @click="loadMyFeedbacks">刷新</el-button>
<el-button v-if="feedbackTab === 'new'" type="primary" :loading="feedbackSubmitting" @click="submitFeedbackForm">提交</el-button>
</template>
</el-dialog>
<el-dialog v-model="settingsOpen" title="个人设置" width="min(720px, 92vw)">
<el-tabs v-model="settingsTab">
<el-tab-pane label="邮箱绑定" name="email">
<div v-loading="emailLoading" class="settings-section">
<el-alert
v-if="emailInfo.email && emailInfo.email_verified"
type="success"
:closable="false"
title="邮箱已绑定并验证"
show-icon
class="settings-alert"
>
<template #default>
<div class="email-row">
<div class="email-value">{{ emailInfo.email }}</div>
<el-button type="danger" text @click="onUnbindEmail">解绑</el-button>
</div>
</template>
</el-alert>
<el-alert
v-else-if="emailInfo.email"
type="warning"
:closable="false"
title="邮箱待验证:请查收验证邮件(含垃圾箱)"
show-icon
class="settings-alert"
/>
<el-form label-position="top">
<el-form-item label="邮箱地址">
<el-input v-model="bindEmailValue" placeholder="name@example.com" />
</el-form-item>
<el-button type="primary" :loading="bindEmailLoading" @click="onBindEmail">发送验证邮件</el-button>
</el-form>
<el-divider />
<div class="notify-row">
<div>
<div class="notify-title">任务完成通知</div>
<div class="app-muted notify-desc">定时任务完成后发送邮件</div>
</div>
<el-switch
:model-value="emailNotifyEnabled"
:disabled="!emailInfo.email_verified || emailNotifyLoading"
inline-prompt
active-text=""
inactive-text=""
@change="onToggleEmailNotify"
/>
</div>
<el-alert
v-if="!emailInfo.email_verified"
type="info"
:closable="false"
title="绑定并验证邮箱后可开启邮件通知。"
show-icon
class="settings-hint"
/>
</div>
</el-tab-pane>
<el-tab-pane label="修改密码" name="password">
<div class="settings-section">
<el-form label-position="top">
<el-form-item label="当前密码">
<el-input v-model="passwordForm.current_password" type="password" show-password autocomplete="current-password" />
</el-form-item>
<el-form-item label="新密码至少6位">
<el-input v-model="passwordForm.new_password" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="passwordForm.confirm_password"
type="password"
show-password
autocomplete="new-password"
@keyup.enter="onChangePassword"
/>
</el-form-item>
<el-button type="primary" :loading="passwordLoading" @click="onChangePassword">确认修改</el-button>
</el-form>
</div>
</el-tab-pane>
<el-tab-pane label="VIP信息" name="vip">
<div class="settings-section">
<el-alert
:type="userStore.isVip ? 'success' : 'info'"
:closable="false"
:title="userStore.isVip ? '当前为 VIP 会员' : '当前为普通用户'"
show-icon
class="settings-alert"
/>
<div v-if="userStore.isVip" class="vip-info">
<div class="vip-line">
<span class="app-muted">到期时间</span>
<span>{{ userStore.vipExpireTime || '未知' }}</span>
</div>
<div class="vip-line">
<span class="app-muted">剩余天数</span>
<span>{{ userStore.vipDaysLeft }}</span>
</div>
</div>
<div v-else class="vip-info">
<div class="app-muted">升级方式请通过反馈联系管理员开通</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="settingsOpen = false">关闭</el-button>
</template>
</el-dialog>
</el-container>
</template>
@@ -232,6 +716,113 @@ async function logout() {
border-top: 1px solid var(--app-border);
}
.announcement-body {
min-height: 80px;
}
.announcement-content {
white-space: pre-wrap;
line-height: 1.6;
font-size: 14px;
}
.feedback-title {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 0;
}
.feedback-title-text {
font-weight: 800;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-time {
margin-left: auto;
font-size: 12px;
white-space: nowrap;
}
.feedback-body {
padding: 6px 0 2px;
}
.feedback-section + .feedback-section {
margin-top: 12px;
}
.feedback-label {
font-size: 12px;
margin-bottom: 6px;
}
.feedback-text {
white-space: pre-wrap;
line-height: 1.6;
font-size: 13px;
}
.settings-section {
padding: 6px 2px 2px;
}
.settings-alert {
margin-bottom: 12px;
}
.email-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.email-value {
font-weight: 800;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.notify-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.notify-title {
font-weight: 800;
}
.notify-desc {
margin-top: 4px;
font-size: 12px;
}
.settings-hint {
margin-top: 10px;
}
.vip-info {
margin-top: 12px;
display: grid;
gap: 10px;
}
.vip-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@media (max-width: 768px) {
.layout-header {
flex-wrap: wrap;