feat: 完成 Passkey 能力与前后台加载优化

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

View File

@@ -9,14 +9,20 @@ 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()
@@ -116,6 +122,13 @@ const passwordForm = reactive({
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)
@@ -237,7 +250,7 @@ async function openSettings() {
}
async function loadSettings() {
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings()])
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings(), loadPasskeys()])
}
async function loadEmailInfo() {
@@ -292,6 +305,116 @@ async function saveKdocsSettings() {
}
}
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) {
@@ -665,6 +788,47 @@ async function dismissAnnouncementPermanently() {
</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">