feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
@@ -19,3 +19,28 @@ export async function logout() {
|
||||
const { data } = await api.post('/logout')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminPasskeys() {
|
||||
const { data } = await api.get('/admin/passkeys')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAdminPasskeyOptions(payload = {}) {
|
||||
const { data } = await api.post('/admin/passkeys/register/options', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAdminPasskeyVerify(payload = {}) {
|
||||
const { data } = await api.post('/admin/passkeys/register/verify', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAdminPasskey(passkeyId) {
|
||||
const { data } = await api.delete(`/admin/passkeys/${passkeyId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function reportAdminPasskeyClientError(payload = {}) {
|
||||
const { data } = await api.post('/admin/passkeys/client-error', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ api.interceptors.response.use(
|
||||
const status = error?.response?.status
|
||||
const payload = error?.response?.data
|
||||
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
||||
const silent = Boolean(error?.config?.__silent)
|
||||
|
||||
if (payload?.code === 'reauth_required' && error?.config && !error.config.__reauth_retry) {
|
||||
try {
|
||||
@@ -120,17 +121,27 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||
if (!silent) {
|
||||
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||
}
|
||||
const pathname = window.location?.pathname || ''
|
||||
if (!pathname.startsWith('/yuyx')) window.location.href = '/yuyx'
|
||||
} else if (status === 403) {
|
||||
toastErrorOnce('403', message || '需要管理员权限', 5000)
|
||||
if (!silent) {
|
||||
toastErrorOnce('403', message || '需要管理员权限', 5000)
|
||||
}
|
||||
} else if (status) {
|
||||
toastErrorOnce(`http:${status}:${message}`, message)
|
||||
if (!silent) {
|
||||
toastErrorOnce(`http:${status}:${message}`, message)
|
||||
}
|
||||
} else if (error?.code === 'ECONNABORTED') {
|
||||
toastErrorOnce('timeout', '请求超时', 3000)
|
||||
if (!silent) {
|
||||
toastErrorOnce('timeout', '请求超时', 3000)
|
||||
}
|
||||
} else {
|
||||
toastErrorOnce(`net:${message}`, message, 3000)
|
||||
if (!silent) {
|
||||
toastErrorOnce(`net:${message}`, message, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from './client'
|
||||
|
||||
export async function fetchKdocsStatus(params = {}) {
|
||||
const { data } = await api.get('/kdocs/status', { params })
|
||||
export async function fetchKdocsStatus(params = {}, requestConfig = {}) {
|
||||
const { data } = await api.get('/kdocs/status', { params, ...requestConfig })
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { logout, updateAdminPassword, updateAdminUsername } from '../api/admin'
|
||||
import {
|
||||
createAdminPasskeyOptions,
|
||||
createAdminPasskeyVerify,
|
||||
deleteAdminPasskey,
|
||||
fetchAdminPasskeys,
|
||||
logout,
|
||||
reportAdminPasskeyClientError,
|
||||
updateAdminPassword,
|
||||
updateAdminUsername,
|
||||
} from '../api/admin'
|
||||
import { createPasskey, isPasskeyAvailable } from '../utils/passkey'
|
||||
|
||||
const username = ref('')
|
||||
const currentPassword = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const submitting = ref(false)
|
||||
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 validateStrongPassword(value) {
|
||||
const text = String(value || '')
|
||||
@@ -108,6 +125,120 @@ async function savePassword() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPasskeys() {
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
const data = await fetchAdminPasskeys()
|
||||
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 createAdminPasskeyOptions({})
|
||||
passkeyRegisterOptions.value = res
|
||||
passkeyRegisterOptionsAt.value = Date.now()
|
||||
} catch {
|
||||
passkeyRegisterOptions.value = null
|
||||
passkeyRegisterOptionsAt.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function addPasskey() {
|
||||
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 createAdminPasskeyOptions({})
|
||||
}
|
||||
const credential = await createPasskey(optionsRes?.publicKey || {})
|
||||
await createAdminPasskeyVerify({ credential, device_name: passkeyDeviceName.value.trim() })
|
||||
passkeyRegisterOptions.value = null
|
||||
passkeyRegisterOptionsAt.value = 0
|
||||
passkeyDeviceName.value = ''
|
||||
ElMessage.success('Passkey设备添加成功')
|
||||
await loadPasskeys()
|
||||
} catch (e) {
|
||||
try {
|
||||
await reportAdminPasskeyClientError({
|
||||
stage: 'register',
|
||||
source: 'admin-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 removePasskey(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除设备「${item?.device_name || '未命名设备'}」吗?`, '删除Passkey设备', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminPasskey(item.id)
|
||||
ElMessage.success('设备已删除')
|
||||
await loadPasskeys()
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPasskeys()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,6 +294,46 @@ async function savePassword() {
|
||||
<el-button type="primary" :loading="submitting" @click="savePassword">保存密码</el-button>
|
||||
<div class="help">建议使用更强密码(至少8位且包含字母与数字)。</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||
<h3 class="section-title">Passkey设备</h3>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="最多可绑定3台设备,可用于管理员无密码登录。"
|
||||
show-icon
|
||||
class="help-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="addPasskey">添加Passkey设备</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-loading="passkeyLoading">
|
||||
<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="removePasskey(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -193,4 +364,8 @@ async function savePassword() {
|
||||
font-size: 12px;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
|
||||
.help-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,7 @@ const kdocsPolling = ref(false)
|
||||
const kdocsStatusLoading = ref(false)
|
||||
const kdocsQrLoading = ref(false)
|
||||
const kdocsClearLoading = ref(false)
|
||||
const kdocsSilentRefreshing = ref(false)
|
||||
const kdocsActionHint = ref('')
|
||||
let kdocsPollingTimer = null
|
||||
|
||||
@@ -47,6 +48,27 @@ const kdocsActionBusy = computed(
|
||||
() => kdocsStatusLoading.value || kdocsQrLoading.value || kdocsClearLoading.value,
|
||||
)
|
||||
|
||||
const kdocsDetecting = computed(
|
||||
() => kdocsSilentRefreshing.value || kdocsStatusLoading.value || kdocsPolling.value,
|
||||
)
|
||||
|
||||
const kdocsStatusText = computed(() => {
|
||||
if (kdocsDetecting.value) return '检测中'
|
||||
const status = kdocsStatus.value || {}
|
||||
if (status?.logged_in === true || status?.last_login_ok === true) return '已登录'
|
||||
if (status?.logged_in === false || status?.last_login_ok === false || status?.login_required === true) return '未登录'
|
||||
if (status?.last_error) return '异常'
|
||||
return '未知'
|
||||
})
|
||||
|
||||
const kdocsStatusClass = computed(() => {
|
||||
if (kdocsDetecting.value) return 'is-checking'
|
||||
if (kdocsStatusText.value === '已登录') return 'is-online'
|
||||
if (kdocsStatusText.value === '未登录') return 'is-offline'
|
||||
if (kdocsStatusText.value === '异常') return 'is-error'
|
||||
return 'is-unknown'
|
||||
})
|
||||
|
||||
function setKdocsHint(message) {
|
||||
if (!message) {
|
||||
kdocsActionHint.value = ''
|
||||
@@ -59,10 +81,9 @@ function setKdocsHint(message) {
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [system, proxy, kdocsInfo] = await Promise.all([
|
||||
const [system, proxy] = await Promise.all([
|
||||
fetchSystemConfig(),
|
||||
fetchProxyConfig(),
|
||||
fetchKdocsStatus().catch(() => ({})),
|
||||
])
|
||||
|
||||
maxConcurrentGlobal.value = system.max_concurrent_global ?? 2
|
||||
@@ -89,12 +110,34 @@ async function loadAll() {
|
||||
kdocsRowEnd.value = system.kdocs_row_end ?? 0
|
||||
kdocsAdminNotifyEnabled.value = (system.kdocs_admin_notify_enabled ?? 0) === 1
|
||||
kdocsAdminNotifyEmail.value = system.kdocs_admin_notify_email || ''
|
||||
kdocsStatus.value = kdocsInfo || {}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 金山登录状态改为静默异步获取,避免阻塞系统配置首屏渲染
|
||||
void refreshKdocsStatusSilently()
|
||||
}
|
||||
|
||||
async function refreshKdocsStatusSilently() {
|
||||
if (kdocsSilentRefreshing.value || kdocsStatusLoading.value) return
|
||||
kdocsSilentRefreshing.value = true
|
||||
try {
|
||||
const status = await fetchKdocsStatus(
|
||||
{},
|
||||
{
|
||||
__silent: true,
|
||||
__no_retry: true,
|
||||
timeout: 8000,
|
||||
},
|
||||
)
|
||||
kdocsStatus.value = status || {}
|
||||
} catch {
|
||||
// silent mode
|
||||
} finally {
|
||||
kdocsSilentRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConcurrency() {
|
||||
@@ -408,9 +451,12 @@ onMounted(loadAll)
|
||||
<h3 class="section-title">金山文档上传</h3>
|
||||
<div class="status-inline app-muted">
|
||||
<span>登录状态:</span>
|
||||
<span v-if="kdocsStatus.last_login_ok === true">已登录</span>
|
||||
<span v-else-if="kdocsStatus.login_required">需要扫码</span>
|
||||
<span v-else>未知</span>
|
||||
<span class="status-chip" :class="kdocsStatusClass">
|
||||
{{ kdocsStatusText }}
|
||||
<span v-if="kdocsDetecting" class="status-dots" aria-hidden="true">
|
||||
<i></i><i></i><i></i>
|
||||
</span>
|
||||
</span>
|
||||
<span>· 待上传 {{ kdocsStatus.queue_size || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -547,6 +593,87 @@ onMounted(loadAll)
|
||||
|
||||
.status-inline {
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-chip.is-checking {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.status-chip.is-online {
|
||||
color: #065f46;
|
||||
background: #d1fae5;
|
||||
border-color: #6ee7b7;
|
||||
}
|
||||
|
||||
.status-chip.is-offline {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
.status-chip.is-error {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.status-chip.is-unknown {
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.status-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.status-dots i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.25;
|
||||
animation: dotPulse 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.status-dots i:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.status-dots i:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.kdocs-form {
|
||||
|
||||
100
admin-frontend/src/utils/passkey.js
Normal file
100
admin-frontend/src/utils/passkey.js
Normal file
@@ -0,0 +1,100 @@
|
||||
function ensurePublicKeyOptions(options) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new Error('Passkey参数无效')
|
||||
}
|
||||
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
|
||||
}
|
||||
|
||||
function base64UrlToUint8Array(base64url) {
|
||||
const value = String(base64url || '')
|
||||
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = window.atob(base64)
|
||||
const bytes = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
bytes[i] = raw.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64Url(input) {
|
||||
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || [])
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function toCreationOptions(rawOptions) {
|
||||
const options = ensurePublicKeyOptions(rawOptions)
|
||||
const normalized = {
|
||||
...options,
|
||||
challenge: base64UrlToUint8Array(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64UrlToUint8Array(options.user?.id),
|
||||
},
|
||||
}
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
normalized.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToUint8Array(item.id),
|
||||
}))
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function serializeCredential(credential) {
|
||||
if (!credential) return null
|
||||
|
||||
const response = credential.response || {}
|
||||
const output = {
|
||||
id: credential.id,
|
||||
rawId: uint8ArrayToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment || undefined,
|
||||
response: {},
|
||||
}
|
||||
|
||||
if (response.clientDataJSON) {
|
||||
output.response.clientDataJSON = uint8ArrayToBase64Url(response.clientDataJSON)
|
||||
}
|
||||
if (response.attestationObject) {
|
||||
output.response.attestationObject = uint8ArrayToBase64Url(response.attestationObject)
|
||||
}
|
||||
if (response.authenticatorData) {
|
||||
output.response.authenticatorData = uint8ArrayToBase64Url(response.authenticatorData)
|
||||
}
|
||||
if (response.signature) {
|
||||
output.response.signature = uint8ArrayToBase64Url(response.signature)
|
||||
}
|
||||
|
||||
if (response.userHandle) {
|
||||
output.response.userHandle = uint8ArrayToBase64Url(response.userHandle)
|
||||
} else {
|
||||
output.response.userHandle = null
|
||||
}
|
||||
|
||||
if (typeof response.getTransports === 'function') {
|
||||
output.response.transports = response.getTransports() || []
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function isPasskeyAvailable() {
|
||||
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
|
||||
}
|
||||
|
||||
export async function createPasskey(rawOptions) {
|
||||
const publicKey = toCreationOptions(rawOptions)
|
||||
const credential = await navigator.credentials.create({ publicKey })
|
||||
return serializeCredential(credential)
|
||||
}
|
||||
Reference in New Issue
Block a user