Files
zsglpt/app-frontend/src/layouts/AppLayout.vue
yuyx 7007f5f6f5 feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
2026-02-15 23:51:46 +08:00

1130 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>