@@ -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 : 1 px solid var ( -- app - border ) ;
}
. announcement - body {
min - height : 80 px ;
}
. announcement - content {
white - space : pre - wrap ;
line - height : 1.6 ;
font - size : 14 px ;
}
. feedback - title {
display : flex ;
align - items : center ;
gap : 10 px ;
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 : 12 px ;
white - space : nowrap ;
}
. feedback - body {
padding : 6 px 0 2 px ;
}
. feedback - section + . feedback - section {
margin - top : 12 px ;
}
. feedback - label {
font - size : 12 px ;
margin - bottom : 6 px ;
}
. feedback - text {
white - space : pre - wrap ;
line - height : 1.6 ;
font - size : 13 px ;
}
. settings - section {
padding : 6 px 2 px 2 px ;
}
. settings - alert {
margin - bottom : 12 px ;
}
. email - row {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
}
. 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 : 12 px ;
flex - wrap : wrap ;
}
. notify - title {
font - weight : 800 ;
}
. notify - desc {
margin - top : 4 px ;
font - size : 12 px ;
}
. settings - hint {
margin - top : 10 px ;
}
. vip - info {
margin - top : 12 px ;
display : grid ;
gap : 10 px ;
}
. vip - line {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
}
@ media ( max - width : 768 px ) {
. layout - header {
flex - wrap : wrap ;