feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user