更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
1130 lines
32 KiB
Vue
1130 lines
32 KiB
Vue
<script setup>
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
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,
|
||
createUserPasskeyOptions,
|
||
createUserPasskeyVerify,
|
||
deleteUserPasskey,
|
||
fetchEmailNotify,
|
||
fetchUserPasskeys,
|
||
fetchUserEmail,
|
||
fetchKdocsSettings,
|
||
reportUserPasskeyClientError,
|
||
unbindEmail,
|
||
updateKdocsSettings,
|
||
updateEmailNotify,
|
||
} from '../api/settings'
|
||
import { useUserStore } from '../stores/user'
|
||
import { createPasskey, isPasskeyAvailable } from '../utils/passkey'
|
||
import { validateStrongPassword } from '../utils/password'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
|
||
const isMobile = ref(false)
|
||
const drawerOpen = ref(false)
|
||
let mediaQuery
|
||
|
||
const announcementOpen = ref(false)
|
||
const announcement = ref(null)
|
||
const announcementLoading = ref(false)
|
||
|
||
const announcementPageToken = (() => {
|
||
try {
|
||
const timeOrigin = window.performance?.timeOrigin
|
||
if (typeof timeOrigin === 'number' && Number.isFinite(timeOrigin)) return String(timeOrigin)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return String(Date.now())
|
||
})()
|
||
|
||
function announcementOnceKey(announcementId) {
|
||
return `announcement_closed_once_${announcementId}`
|
||
}
|
||
|
||
function announcementPermanentKey(announcementId) {
|
||
return `announcement_closed_${announcementId}`
|
||
}
|
||
|
||
function wasAnnouncementClosedOnce(announcementId) {
|
||
try {
|
||
return window.sessionStorage.getItem(announcementOnceKey(announcementId)) === announcementPageToken
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function wasAnnouncementClosedPermanently(announcementId) {
|
||
try {
|
||
return window.localStorage.getItem(announcementPermanentKey(announcementId)) === '1'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function markAnnouncementClosedOnce(announcementId) {
|
||
try {
|
||
window.sessionStorage.setItem(announcementOnceKey(announcementId), announcementPageToken)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function markAnnouncementClosedPermanently(announcementId) {
|
||
try {
|
||
window.localStorage.setItem(announcementPermanentKey(announcementId), '1')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
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: '',
|
||
})
|
||
|
||
const kdocsLoading = ref(false)
|
||
const kdocsSaving = ref(false)
|
||
const kdocsUnitValue = ref('')
|
||
const passkeyLoading = ref(false)
|
||
const passkeyAddLoading = ref(false)
|
||
const passkeyDeviceName = ref('')
|
||
const passkeyItems = ref([])
|
||
const passkeyRegisterOptions = ref(null)
|
||
const passkeyRegisterOptionsAt = ref(0)
|
||
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
|
||
|
||
function syncIsMobile() {
|
||
isMobile.value = Boolean(mediaQuery?.matches)
|
||
if (!isMobile.value) drawerOpen.value = false
|
||
}
|
||
|
||
onMounted(() => {
|
||
mediaQuery = window.matchMedia('(max-width: 768px)')
|
||
mediaQuery.addEventListener?.('change', syncIsMobile)
|
||
syncIsMobile()
|
||
|
||
userStore.refreshVipInfo().catch(() => {
|
||
window.location.href = '/login'
|
||
})
|
||
|
||
loadAnnouncement()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
mediaQuery?.removeEventListener?.('change', syncIsMobile)
|
||
})
|
||
|
||
const menuItems = [
|
||
{ path: '/app/accounts', label: '账号管理', icon: User },
|
||
{ path: '/app/schedules', label: '定时任务', icon: Calendar },
|
||
{ path: '/app/screenshots', label: '截图管理', icon: Camera },
|
||
]
|
||
|
||
const activeMenu = computed(() => route.path)
|
||
|
||
async function go(path) {
|
||
await router.push(path)
|
||
drawerOpen.value = false
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||
confirmButtonText: '退出',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
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(), loadKdocsSettings(), loadPasskeys()])
|
||
}
|
||
|
||
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 loadKdocsSettings() {
|
||
kdocsLoading.value = true
|
||
try {
|
||
const data = await fetchKdocsSettings()
|
||
kdocsUnitValue.value = data?.kdocs_unit || ''
|
||
} catch {
|
||
kdocsUnitValue.value = ''
|
||
} finally {
|
||
kdocsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function saveKdocsSettings() {
|
||
kdocsSaving.value = true
|
||
try {
|
||
await updateKdocsSettings({ kdocs_unit: kdocsUnitValue.value.trim() })
|
||
ElMessage.success('已更新表格县区设置')
|
||
} catch {
|
||
// handled by interceptor
|
||
} finally {
|
||
kdocsSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function loadPasskeys() {
|
||
passkeyLoading.value = true
|
||
try {
|
||
const data = await fetchUserPasskeys()
|
||
passkeyItems.value = Array.isArray(data?.items) ? data.items : []
|
||
if (passkeyItems.value.length < 3) {
|
||
await prefetchPasskeyRegisterOptions()
|
||
} else {
|
||
passkeyRegisterOptions.value = null
|
||
passkeyRegisterOptionsAt.value = 0
|
||
}
|
||
} catch {
|
||
passkeyItems.value = []
|
||
passkeyRegisterOptions.value = null
|
||
passkeyRegisterOptionsAt.value = 0
|
||
} finally {
|
||
passkeyLoading.value = false
|
||
}
|
||
}
|
||
|
||
function getCachedPasskeyRegisterOptions() {
|
||
if (!passkeyRegisterOptions.value) return null
|
||
if (Date.now() - Number(passkeyRegisterOptionsAt.value || 0) > PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS) return null
|
||
return passkeyRegisterOptions.value
|
||
}
|
||
|
||
async function prefetchPasskeyRegisterOptions() {
|
||
try {
|
||
const res = await createUserPasskeyOptions({})
|
||
passkeyRegisterOptions.value = res
|
||
passkeyRegisterOptionsAt.value = Date.now()
|
||
} catch {
|
||
passkeyRegisterOptions.value = null
|
||
passkeyRegisterOptionsAt.value = 0
|
||
}
|
||
}
|
||
|
||
async function onAddPasskey() {
|
||
if (!isPasskeyAvailable()) {
|
||
ElMessage.error('当前浏览器或环境不支持Passkey(需 HTTPS)')
|
||
return
|
||
}
|
||
if (passkeyItems.value.length >= 3) {
|
||
ElMessage.error('最多可绑定3台设备')
|
||
return
|
||
}
|
||
|
||
passkeyAddLoading.value = true
|
||
try {
|
||
let optionsRes = getCachedPasskeyRegisterOptions()
|
||
if (!optionsRes) {
|
||
optionsRes = await createUserPasskeyOptions({})
|
||
}
|
||
const credential = await createPasskey(optionsRes?.publicKey || {})
|
||
await createUserPasskeyVerify({ credential, device_name: passkeyDeviceName.value.trim() })
|
||
passkeyRegisterOptions.value = null
|
||
passkeyRegisterOptionsAt.value = 0
|
||
passkeyDeviceName.value = ''
|
||
ElMessage.success('Passkey设备添加成功')
|
||
await loadPasskeys()
|
||
} catch (e) {
|
||
try {
|
||
await reportUserPasskeyClientError({
|
||
stage: 'register',
|
||
source: 'user-settings',
|
||
name: e?.name || '',
|
||
message: e?.message || '',
|
||
code: e?.code || '',
|
||
user_agent: navigator.userAgent || '',
|
||
})
|
||
} catch {
|
||
// ignore report failure
|
||
}
|
||
passkeyRegisterOptions.value = null
|
||
passkeyRegisterOptionsAt.value = 0
|
||
await prefetchPasskeyRegisterOptions()
|
||
const data = e?.response?.data
|
||
const clientMessage = e?.message ? String(e.message) : ''
|
||
const message =
|
||
data?.error ||
|
||
(e?.name === 'NotAllowedError'
|
||
? `Passkey注册未完成(浏览器返回:${clientMessage || '未提供详细原因'})`
|
||
: clientMessage || 'Passkey添加失败')
|
||
ElMessage.error(message)
|
||
} finally {
|
||
passkeyAddLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function onDeletePasskey(item) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除设备「${item?.device_name || '未命名设备'}」吗?`, '删除Passkey设备', {
|
||
confirmButtonText: '删除',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await deleteUserPasskey(item.id)
|
||
ElMessage.success('设备已删除')
|
||
await loadPasskeys()
|
||
} catch (e) {
|
||
const data = e?.response?.data
|
||
ElMessage.error(data?.error || '删除失败')
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
const passwordCheck = validateStrongPassword(newPassword)
|
||
if (!passwordCheck.ok) {
|
||
ElMessage.error(passwordCheck.message)
|
||
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
|
||
|
||
if (wasAnnouncementClosedPermanently(ann.id)) return
|
||
if (wasAnnouncementClosedOnce(ann.id)) return
|
||
|
||
announcement.value = ann
|
||
announcementOpen.value = true
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
announcementLoading.value = false
|
||
}
|
||
}
|
||
|
||
function closeAnnouncementOnce() {
|
||
const ann = announcement.value
|
||
if (ann?.id) markAnnouncementClosedOnce(ann.id)
|
||
announcementOpen.value = false
|
||
}
|
||
|
||
async function dismissAnnouncementPermanently() {
|
||
const ann = announcement.value
|
||
if (!ann?.id) {
|
||
announcementOpen.value = false
|
||
return
|
||
}
|
||
markAnnouncementClosedPermanently(ann.id)
|
||
try {
|
||
const res = await dismissAnnouncement(ann.id)
|
||
if (res?.success) ElMessage.success('已永久关闭')
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
announcementOpen.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<el-container class="layout-root">
|
||
<el-aside v-if="!isMobile" width="220px" class="layout-aside">
|
||
<div class="brand">
|
||
<div class="brand-title">知识管理平台</div>
|
||
<div class="brand-sub app-muted">用户中心</div>
|
||
</div>
|
||
|
||
<el-menu :default-active="activeMenu" class="aside-menu" router @select="go">
|
||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||
<el-icon><component :is="item.icon" /></el-icon>
|
||
<span>{{ item.label }}</span>
|
||
</el-menu-item>
|
||
</el-menu>
|
||
</el-aside>
|
||
|
||
<el-container>
|
||
<el-header class="layout-header">
|
||
<div class="header-left">
|
||
<el-button v-if="isMobile" text class="header-menu-btn" @click="drawerOpen = true">
|
||
菜单
|
||
</el-button>
|
||
<div class="header-title">用户控制台</div>
|
||
</div>
|
||
|
||
<div class="header-right">
|
||
<div class="user-meta">
|
||
<el-tag v-if="userStore.isVip" type="success" size="small" effect="light">VIP</el-tag>
|
||
<el-tag v-else type="info" size="small" effect="light">普通</el-tag>
|
||
<span class="user-name">{{ userStore.username || '用户' }}</span>
|
||
<span v-if="userStore.isVip && userStore.vipDaysLeft <= 7 && userStore.vipDaysLeft > 0" class="vip-warn">
|
||
({{ 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>
|
||
|
||
<el-main class="layout-main">
|
||
<RouterView />
|
||
</el-main>
|
||
</el-container>
|
||
|
||
<el-drawer v-model="drawerOpen" size="240px" :with-header="false">
|
||
<div class="drawer-brand">
|
||
<div class="brand-title">知识管理平台</div>
|
||
<div class="brand-sub app-muted">用户中心</div>
|
||
</div>
|
||
<div class="drawer-user">
|
||
<el-tag v-if="userStore.isVip" type="success" size="small" effect="light">VIP</el-tag>
|
||
<el-tag v-else type="info" size="small" effect="light">普通</el-tag>
|
||
<span class="user-name">{{ userStore.username || '用户' }}</span>
|
||
</div>
|
||
<el-menu :default-active="activeMenu" class="aside-menu" router @select="go">
|
||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||
<el-icon><component :is="item.icon" /></el-icon>
|
||
<span>{{ item.label }}</span>
|
||
</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 v-if="announcement?.image_url" class="announcement-image">
|
||
<img :src="announcement.image_url" alt="公告图片" loading="lazy" />
|
||
</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="新密码(至少8位且包含字母和数字)">
|
||
<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="Passkey设备" name="passkeys">
|
||
<div class="settings-section" v-loading="passkeyLoading">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
title="最多可绑定3台设备,用于无密码登录。"
|
||
show-icon
|
||
class="settings-alert"
|
||
/>
|
||
|
||
<el-form inline>
|
||
<el-form-item label="设备备注">
|
||
<el-input
|
||
v-model="passkeyDeviceName"
|
||
placeholder="例如:我的iPhone / 办公Mac"
|
||
maxlength="40"
|
||
show-word-limit
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="passkeyAddLoading" @click="onAddPasskey">
|
||
添加Passkey设备
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-empty v-if="passkeyItems.length === 0" description="暂无Passkey设备" />
|
||
<el-table v-else :data="passkeyItems" size="small" style="width: 100%">
|
||
<el-table-column prop="device_name" label="设备备注" min-width="160" />
|
||
<el-table-column prop="credential_id_preview" label="凭据ID" min-width="180" />
|
||
<el-table-column prop="last_used_at" label="最近使用" min-width="140" />
|
||
<el-table-column prop="created_at" label="创建时间" min-width="140" />
|
||
<el-table-column label="操作" width="100" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button type="danger" text @click="onDeletePasskey(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="表格上传" name="kdocs">
|
||
<div v-loading="kdocsLoading" class="settings-section">
|
||
<el-form label-position="top">
|
||
<el-form-item label="县区(可选)">
|
||
<el-input v-model="kdocsUnitValue" placeholder="留空使用系统默认县区" />
|
||
</el-form-item>
|
||
<el-button type="primary" :loading="kdocsSaving" @click="saveKdocsSettings">保存</el-button>
|
||
</el-form>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
title="自动上传开关在“账号管理”页面设置(测试功能)。"
|
||
show-icon
|
||
class="settings-hint"
|
||
/>
|
||
</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>
|
||
|
||
<style scoped>
|
||
.layout-root {
|
||
height: 100%;
|
||
}
|
||
|
||
.layout-aside {
|
||
background: #ffffff;
|
||
border-right: 1px solid var(--app-border);
|
||
}
|
||
|
||
.brand,
|
||
.drawer-brand {
|
||
padding: 18px 16px 10px;
|
||
}
|
||
|
||
.brand-title {
|
||
font-size: 15px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.2px;
|
||
}
|
||
|
||
.brand-sub {
|
||
margin-top: 2px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.aside-menu {
|
||
border-right: none;
|
||
}
|
||
|
||
.layout-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
background: rgba(246, 247, 251, 0.6);
|
||
backdrop-filter: saturate(180%) blur(10px);
|
||
border-bottom: 1px solid var(--app-border);
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.header-menu-btn {
|
||
padding-left: 0;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.user-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
max-width: 180px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.vip-warn {
|
||
font-size: 12px;
|
||
color: var(--app-muted);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.layout-main {
|
||
padding: 16px;
|
||
}
|
||
|
||
.drawer-user {
|
||
padding: 0 16px 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.drawer-actions {
|
||
padding: 12px 16px 4px;
|
||
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;
|
||
}
|
||
|
||
.announcement-image {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.announcement-image img {
|
||
max-width: 100%;
|
||
max-height: 320px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--app-border);
|
||
object-fit: contain;
|
||
}
|
||
|
||
.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;
|
||
height: auto;
|
||
padding-top: 10px;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
.header-right {
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.layout-main {
|
||
padding: 12px;
|
||
}
|
||
|
||
.user-name {
|
||
max-width: 120px;
|
||
}
|
||
}
|
||
</style>
|