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)
|
||||
}
|
||||
13
app-frontend/login.html
Normal file
13
app-frontend/login.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>知识管理平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>该页面需要启用 JavaScript 才能使用。</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/login-main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
389
app-frontend/package-lock.json
generated
389
app-frontend/package-lock.json
generated
@@ -18,6 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
},
|
||||
@@ -552,12 +554,55 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.7",
|
||||
@@ -1157,6 +1202,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@@ -1202,6 +1260,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1214,6 +1288,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
@@ -1427,12 +1508,32 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1617,6 +1718,31 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -1693,6 +1819,38 @@
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
|
||||
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1723,6 +1881,24 @@
|
||||
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
@@ -1741,7 +1917,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1770,6 +1945,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
"exsolve": "^1.0.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -1804,6 +1991,37 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@@ -1852,6 +2070,13 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
@@ -1898,6 +2123,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
@@ -1927,6 +2165,148 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unimport": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/unimport/-/unimport-5.6.0.tgz",
|
||||
"integrity": "sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"pkg-types": "^2.3.0",
|
||||
"scule": "^1.3.0",
|
||||
"strip-literal": "^3.1.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unimport/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-auto-import": {
|
||||
"version": "21.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz",
|
||||
"integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"picomatch": "^4.0.3",
|
||||
"unimport": "^5.6.0",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^4.0.0",
|
||||
"@vueuse/core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
|
||||
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "31.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz",
|
||||
"integrity": "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"obug": "^2.1.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unplugin": "^2.3.11",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
||||
@@ -2046,6 +2426,13 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ export async function login(payload) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function passkeyLoginOptions(payload) {
|
||||
const { data } = await publicApi.post('/passkeys/login/options', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function passkeyLoginVerify(payload) {
|
||||
const { data } = await publicApi.post('/passkeys/login/verify', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function register(payload) {
|
||||
const { data } = await publicApi.post('/register', payload)
|
||||
return data
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
let lastToastKey = ''
|
||||
let lastToastAt = 0
|
||||
@@ -7,13 +6,76 @@ let lastToastAt = 0
|
||||
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
|
||||
const MAX_RETRY_COUNT = 1
|
||||
const RETRY_BASE_DELAY_MS = 300
|
||||
const TOAST_STYLE_ID = 'zsglpt-lite-toast-style'
|
||||
|
||||
function ensureToastStyle() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.getElementById(TOAST_STYLE_ID)) return
|
||||
const style = document.createElement('style')
|
||||
style.id = TOAST_STYLE_ID
|
||||
style.textContent = `
|
||||
.zsglpt-lite-toast-wrap {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.zsglpt-lite-toast {
|
||||
max-width: min(88vw, 420px);
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.24);
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition: all .18s ease;
|
||||
}
|
||||
.zsglpt-lite-toast.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.zsglpt-lite-toast.is-error {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
function ensureToastWrap() {
|
||||
if (typeof document === 'undefined') return null
|
||||
ensureToastStyle()
|
||||
let wrap = document.querySelector('.zsglpt-lite-toast-wrap')
|
||||
if (wrap) return wrap
|
||||
wrap = document.createElement('div')
|
||||
wrap.className = 'zsglpt-lite-toast-wrap'
|
||||
document.body.appendChild(wrap)
|
||||
return wrap
|
||||
}
|
||||
|
||||
function showLiteToast(message) {
|
||||
const wrap = ensureToastWrap()
|
||||
if (!wrap) return
|
||||
const node = document.createElement('div')
|
||||
node.className = 'zsglpt-lite-toast is-error'
|
||||
node.textContent = String(message || '请求失败')
|
||||
wrap.appendChild(node)
|
||||
requestAnimationFrame(() => node.classList.add('is-visible'))
|
||||
window.setTimeout(() => node.classList.remove('is-visible'), 2300)
|
||||
window.setTimeout(() => node.remove(), 2600)
|
||||
}
|
||||
|
||||
function toastErrorOnce(key, message, minIntervalMs = 1500) {
|
||||
const now = Date.now()
|
||||
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
|
||||
lastToastKey = key
|
||||
lastToastAt = now
|
||||
ElMessage.error(message)
|
||||
showLiteToast(message)
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function fetchSchedules() {
|
||||
const { data } = await publicApi.get('/schedules')
|
||||
export async function fetchSchedules(params = {}) {
|
||||
const { data } = await publicApi.get('/schedules', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -39,4 +39,3 @@ export async function clearScheduleLogs(scheduleId) {
|
||||
const { data } = await publicApi.delete(`/schedules/${scheduleId}/logs`)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function fetchScreenshots() {
|
||||
const { data } = await publicApi.get('/screenshots')
|
||||
export async function fetchScreenshots(params = {}) {
|
||||
const { data } = await publicApi.get('/screenshots', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -14,4 +14,3 @@ export async function clearScreenshots() {
|
||||
const { data } = await publicApi.post('/screenshots/clear', {})
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -44,3 +44,28 @@ export async function fetchKdocsStatus() {
|
||||
const { data } = await publicApi.get('/kdocs/status')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchUserPasskeys() {
|
||||
const { data } = await publicApi.get('/user/passkeys')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUserPasskeyOptions(payload) {
|
||||
const { data } = await publicApi.post('/user/passkeys/register/options', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUserPasskeyVerify(payload) {
|
||||
const { data } = await publicApi.post('/user/passkeys/register/verify', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUserPasskey(passkeyId) {
|
||||
const { data } = await publicApi.delete(`/user/passkeys/${passkeyId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function reportUserPasskeyClientError(payload) {
|
||||
const { data } = await publicApi.post('/user/passkeys/client-error', payload || {})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
6
app-frontend/src/login-main.js
Normal file
6
app-frontend/src/login-main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import LoginPage from './pages/LoginPage.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(LoginPage).mount('#app')
|
||||
@@ -5,11 +5,6 @@ import router from './router'
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
fetchEmailVerifyStatus,
|
||||
forgotPassword,
|
||||
generateCaptcha,
|
||||
login,
|
||||
resendVerifyEmail,
|
||||
} from '../api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
@@ -23,10 +11,14 @@ const needCaptcha = ref(false)
|
||||
const captchaImage = ref('')
|
||||
const captchaSession = ref('')
|
||||
const loading = ref(false)
|
||||
const passkeyLoading = ref(false)
|
||||
|
||||
const emailEnabled = ref(false)
|
||||
const registerVerifyEnabled = ref(false)
|
||||
|
||||
const noticeType = ref('')
|
||||
const noticeText = ref('')
|
||||
|
||||
const forgotOpen = ref(false)
|
||||
const resendOpen = ref(false)
|
||||
|
||||
@@ -38,6 +30,7 @@ const forgotCaptchaImage = ref('')
|
||||
const forgotCaptchaSession = ref('')
|
||||
const forgotLoading = ref(false)
|
||||
const forgotHint = ref('')
|
||||
const forgotError = ref('')
|
||||
|
||||
const resendForm = reactive({
|
||||
email: '',
|
||||
@@ -46,8 +39,172 @@ const resendForm = reactive({
|
||||
const resendCaptchaImage = ref('')
|
||||
const resendCaptchaSession = ref('')
|
||||
const resendLoading = ref(false)
|
||||
const resendError = ref('')
|
||||
|
||||
const showResendLink = computed(() => Boolean(registerVerifyEnabled.value))
|
||||
const showResendLink = computed(() => true)
|
||||
const verifyStatusLoaded = ref(false)
|
||||
|
||||
function getCookie(name) {
|
||||
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1')
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`))
|
||||
return match ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status, data) {
|
||||
super(message || '请求失败')
|
||||
this.name = 'ApiError'
|
||||
this.response = {
|
||||
status: Number(status || 0),
|
||||
data: data || {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function apiRequest(path, options = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase()
|
||||
const headers = {
|
||||
...(options.headers || {}),
|
||||
}
|
||||
const hasBody = Object.prototype.hasOwnProperty.call(options, 'body')
|
||||
if (hasBody && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const token = getCookie('csrf_token')
|
||||
if (token) {
|
||||
headers['X-CSRF-Token'] = token
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: hasBody ? JSON.stringify(options.body ?? {}) : undefined,
|
||||
})
|
||||
|
||||
let data = {}
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(data?.error || data?.message || `请求失败 (${response.status})`, response.status, data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const fetchEmailVerifyStatus = () => apiRequest('/email/verify-status')
|
||||
const generateCaptcha = () => apiRequest('/generate_captcha', { method: 'POST', body: {} })
|
||||
const loginRequest = (payload) => apiRequest('/login', { method: 'POST', body: payload || {} })
|
||||
const passkeyLoginOptions = (payload) => apiRequest('/passkeys/login/options', { method: 'POST', body: payload || {} })
|
||||
const passkeyLoginVerify = (payload) => apiRequest('/passkeys/login/verify', { method: 'POST', body: payload || {} })
|
||||
const resendVerifyEmail = (payload) => apiRequest('/resend-verify-email', { method: 'POST', body: payload || {} })
|
||||
const forgotPassword = (payload) => apiRequest('/forgot-password', { method: 'POST', body: payload || {} })
|
||||
|
||||
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 normalizePublicKeyOptions(options) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new Error('Passkey参数无效')
|
||||
}
|
||||
return options.publicKey && typeof options.publicKey === 'object' ? options.publicKey : options
|
||||
}
|
||||
|
||||
function toRequestOptions(rawOptions) {
|
||||
const options = normalizePublicKeyOptions(rawOptions)
|
||||
const normalized = {
|
||||
...options,
|
||||
challenge: base64UrlToUint8Array(options.challenge),
|
||||
}
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
normalized.allowCredentials = options.allowCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToUint8Array(item.id),
|
||||
}))
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function serializeCredential(credential) {
|
||||
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.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
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function isPasskeyAvailable() {
|
||||
return typeof window !== 'undefined' && window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials
|
||||
}
|
||||
|
||||
async function authenticateWithPasskey(rawOptions) {
|
||||
const publicKey = toRequestOptions(rawOptions)
|
||||
const credential = await navigator.credentials.get({ publicKey })
|
||||
return serializeCredential(credential)
|
||||
}
|
||||
|
||||
async function loadVerifyStatus() {
|
||||
if (verifyStatusLoaded.value) return
|
||||
try {
|
||||
const status = await fetchEmailVerifyStatus()
|
||||
emailEnabled.value = Boolean(status?.email_enabled)
|
||||
registerVerifyEnabled.value = Boolean(status?.register_verify_enabled)
|
||||
} catch {
|
||||
emailEnabled.value = false
|
||||
registerVerifyEnabled.value = false
|
||||
} finally {
|
||||
verifyStatusLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function setNotice(type, text) {
|
||||
noticeType.value = String(type || '')
|
||||
noticeText.value = String(text || '')
|
||||
}
|
||||
|
||||
function clearNotice() {
|
||||
noticeType.value = ''
|
||||
noticeText.value = ''
|
||||
}
|
||||
|
||||
async function refreshLoginCaptcha() {
|
||||
try {
|
||||
@@ -85,41 +242,45 @@ async function refreshResendCaptcha() {
|
||||
}
|
||||
}
|
||||
|
||||
function redirectAfterLogin() {
|
||||
const urlParams = new URLSearchParams(window.location.search || '')
|
||||
const next = String(urlParams.get('next') || '').trim()
|
||||
const safeNext = next && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : ''
|
||||
window.setTimeout(() => {
|
||||
window.location.href = safeNext || '/app'
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
clearNotice()
|
||||
|
||||
if (!form.username.trim() || !form.password.trim()) {
|
||||
ElMessage.error('用户名和密码不能为空')
|
||||
setNotice('error', '用户名和密码不能为空')
|
||||
return
|
||||
}
|
||||
if (needCaptcha.value && !form.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
setNotice('error', '请输入验证码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await login({
|
||||
username: form.username.trim(),
|
||||
const username = form.username.trim()
|
||||
await loginRequest({
|
||||
username,
|
||||
password: form.password,
|
||||
captcha_session: captchaSession.value,
|
||||
captcha: form.captcha.trim(),
|
||||
need_captcha: needCaptcha.value,
|
||||
})
|
||||
ElMessage.success('登录成功,正在跳转...')
|
||||
const urlParams = new URLSearchParams(window.location.search || '')
|
||||
const next = String(urlParams.get('next') || '').trim()
|
||||
const safeNext = next && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : ''
|
||||
setTimeout(() => {
|
||||
const target = safeNext || '/app'
|
||||
router.push(target).catch(() => {
|
||||
window.location.href = target
|
||||
})
|
||||
}, 300)
|
||||
setNotice('success', '登录成功,正在跳转...')
|
||||
redirectAfterLogin()
|
||||
} catch (e) {
|
||||
const status = e?.response?.status
|
||||
const data = e?.response?.data
|
||||
const message = data?.error || data?.message || '登录失败'
|
||||
|
||||
ElMessage.error(message)
|
||||
setNotice('error', message)
|
||||
|
||||
if (data?.need_captcha) {
|
||||
needCaptcha.value = true
|
||||
@@ -132,29 +293,59 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onPasskeyLogin() {
|
||||
clearNotice()
|
||||
|
||||
const username = form.username.trim()
|
||||
if (!isPasskeyAvailable()) {
|
||||
setNotice('error', '当前浏览器或环境不支持Passkey(需 HTTPS)')
|
||||
return
|
||||
}
|
||||
|
||||
passkeyLoading.value = true
|
||||
try {
|
||||
const optionsRes = await passkeyLoginOptions(username ? { username } : {})
|
||||
const credential = await authenticateWithPasskey(optionsRes?.publicKey || {})
|
||||
await passkeyLoginVerify(username ? { username, credential } : { credential })
|
||||
setNotice('success', 'Passkey 登录成功,正在跳转...')
|
||||
redirectAfterLogin()
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
const message =
|
||||
data?.error ||
|
||||
(e?.name === 'NotAllowedError' ? 'Passkey验证未完成(可能取消、超时或设备未响应)' : e?.message || 'Passkey登录失败')
|
||||
setNotice('error', message)
|
||||
} finally {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openForgot() {
|
||||
await loadVerifyStatus()
|
||||
forgotOpen.value = true
|
||||
forgotHint.value = ''
|
||||
forgotError.value = ''
|
||||
forgotForm.username = ''
|
||||
forgotForm.captcha = ''
|
||||
await refreshEmailResetCaptcha()
|
||||
}
|
||||
|
||||
async function submitForgot() {
|
||||
forgotError.value = ''
|
||||
forgotHint.value = ''
|
||||
|
||||
if (!emailEnabled.value) {
|
||||
ElMessage.warning('邮件功能未启用,请联系管理员重置密码。')
|
||||
forgotError.value = '邮件功能未启用,请联系管理员重置密码。'
|
||||
return
|
||||
}
|
||||
|
||||
const username = forgotForm.username.trim()
|
||||
if (!username) {
|
||||
ElMessage.error('请输入用户名')
|
||||
forgotError.value = '请输入用户名'
|
||||
return
|
||||
}
|
||||
if (!forgotForm.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
forgotError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,17 +356,15 @@ async function submitForgot() {
|
||||
captcha_session: forgotCaptchaSession.value,
|
||||
captcha: forgotForm.captcha.trim(),
|
||||
})
|
||||
ElMessage.success(res?.message || '已发送重置邮件')
|
||||
setTimeout(() => {
|
||||
forgotOpen.value = false
|
||||
}, 800)
|
||||
setNotice('success', res?.message || '已发送重置邮件')
|
||||
forgotOpen.value = false
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
const message = data?.error || '发送失败'
|
||||
if (data?.code === 'email_not_bound') {
|
||||
forgotHint.value = message
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
forgotError.value = message
|
||||
}
|
||||
await refreshEmailResetCaptcha()
|
||||
} finally {
|
||||
@@ -184,20 +373,28 @@ async function submitForgot() {
|
||||
}
|
||||
|
||||
async function openResend() {
|
||||
await loadVerifyStatus()
|
||||
if (!registerVerifyEnabled.value) {
|
||||
setNotice('error', '当前未启用注册邮箱验证,无需重发验证邮件。')
|
||||
return
|
||||
}
|
||||
resendOpen.value = true
|
||||
resendForm.email = ''
|
||||
resendForm.captcha = ''
|
||||
resendError.value = ''
|
||||
await refreshResendCaptcha()
|
||||
}
|
||||
|
||||
async function submitResend() {
|
||||
resendError.value = ''
|
||||
|
||||
const email = resendForm.email.trim()
|
||||
if (!email) {
|
||||
ElMessage.error('请输入邮箱')
|
||||
resendError.value = '请输入邮箱'
|
||||
return
|
||||
}
|
||||
if (!resendForm.captcha.trim()) {
|
||||
ElMessage.error('请输入验证码')
|
||||
resendError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,13 +405,11 @@ async function submitResend() {
|
||||
captcha_session: resendCaptchaSession.value,
|
||||
captcha: resendForm.captcha.trim(),
|
||||
})
|
||||
ElMessage.success(res?.message || '验证邮件已发送,请查收')
|
||||
setTimeout(() => {
|
||||
resendOpen.value = false
|
||||
}, 800)
|
||||
setNotice('success', res?.message || '验证邮件已发送,请查收')
|
||||
resendOpen.value = false
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '发送失败')
|
||||
resendError.value = data?.error || '发送失败'
|
||||
await refreshResendCaptcha()
|
||||
} finally {
|
||||
resendLoading.value = false
|
||||
@@ -222,17 +417,12 @@ async function submitResend() {
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
router.push('/register')
|
||||
window.location.href = '/register'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const status = await fetchEmailVerifyStatus()
|
||||
emailEnabled.value = Boolean(status?.email_enabled)
|
||||
registerVerifyEnabled.value = Boolean(status?.register_verify_enabled)
|
||||
} catch {
|
||||
emailEnabled.value = false
|
||||
registerVerifyEnabled.value = false
|
||||
if (needCaptcha.value) {
|
||||
await refreshLoginCaptcha()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -246,12 +436,16 @@ onMounted(async () => {
|
||||
<p>知识管理平台</p>
|
||||
</div>
|
||||
|
||||
<div v-if="noticeText" class="notice" :class="noticeType === 'success' ? 'is-success' : 'is-error'">
|
||||
{{ noticeText }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">用户账号</label>
|
||||
<el-input
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
class="login-input"
|
||||
class="text-input"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="username"
|
||||
/>
|
||||
@@ -259,12 +453,11 @@ onMounted(async () => {
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<el-input
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="login-input"
|
||||
class="text-input"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="onSubmit"
|
||||
@@ -274,10 +467,10 @@ onMounted(async () => {
|
||||
<div v-if="needCaptcha" class="form-group">
|
||||
<label for="captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input
|
||||
<input
|
||||
id="captcha"
|
||||
v-model="form.captcha"
|
||||
class="login-input captcha-input"
|
||||
class="text-input captcha-input"
|
||||
placeholder="请输入验证码"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
@@ -296,6 +489,9 @@ onMounted(async () => {
|
||||
<button type="button" class="btn-login" :disabled="loading" @click="onSubmit">
|
||||
{{ loading ? '登录中...' : '登录系统' }}
|
||||
</button>
|
||||
<button type="button" class="btn-passkey" :disabled="passkeyLoading" @click="onPasskeyLogin">
|
||||
{{ passkeyLoading ? 'Passkey验证中...' : '使用 Passkey 登录' }}
|
||||
</button>
|
||||
|
||||
<div class="action-links">
|
||||
<button type="button" class="link-btn" @click="openForgot">忘记密码?</button>
|
||||
@@ -308,39 +504,38 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="forgotOpen" title="找回密码" width="min(560px, 92vw)">
|
||||
<el-alert
|
||||
v-if="!emailEnabled"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="邮件功能未启用"
|
||||
description="无法通过邮箱找回密码,请联系管理员重置密码。"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="通过邮箱找回密码"
|
||||
description="输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-if="forgotHint"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="无法通过邮箱找回密码"
|
||||
:description="forgotHint"
|
||||
show-icon
|
||||
class="alert"
|
||||
/>
|
||||
<el-form label-position="top" class="dialog-form">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="forgotForm.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div v-if="forgotOpen" class="modal-mask" @click.self="forgotOpen = false">
|
||||
<section class="modal-card">
|
||||
<div class="modal-head">
|
||||
<h3>找回密码</h3>
|
||||
<button type="button" class="modal-close" @click="forgotOpen = false">关闭</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-tip" :class="{ warn: !emailEnabled }">
|
||||
{{
|
||||
emailEnabled
|
||||
? '输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。'
|
||||
: '邮件功能未启用,无法通过邮箱找回密码。'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<p v-if="forgotHint" class="modal-tip warn">{{ forgotHint }}</p>
|
||||
<p v-if="forgotError" class="modal-tip error">{{ forgotError }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="forgot-username">用户名</label>
|
||||
<input id="forgot-username" v-model="forgotForm.username" class="text-input" placeholder="请输入用户名" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="forgot-captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="forgotForm.captcha" placeholder="请输入验证码" />
|
||||
<input
|
||||
id="forgot-captcha"
|
||||
v-model="forgotForm.captcha"
|
||||
class="text-input captcha-input"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<img
|
||||
v-if="forgotCaptchaImage"
|
||||
class="captcha-img"
|
||||
@@ -349,28 +544,43 @@ onMounted(async () => {
|
||||
title="点击刷新"
|
||||
@click="refreshEmailResetCaptcha"
|
||||
/>
|
||||
<el-button @click="refreshEmailResetCaptcha">刷新</el-button>
|
||||
<button type="button" class="captcha-refresh" @click="refreshEmailResetCaptcha">刷新</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="forgotOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="forgotLoading" :disabled="!emailEnabled" @click="submitForgot">
|
||||
发送重置邮件
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-ghost" @click="forgotOpen = false">取消</button>
|
||||
<button type="button" class="btn-login" :disabled="forgotLoading || !emailEnabled" @click="submitForgot">
|
||||
{{ forgotLoading ? '发送中...' : '发送重置邮件' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="resendOpen" title="重发验证邮件" width="min(520px, 92vw)">
|
||||
<el-alert type="info" :closable="false" title="用于注册邮箱验证:请输入邮箱并完成验证码。" show-icon />
|
||||
<el-form label-position="top" class="dialog-form">
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="resendForm.email" placeholder="name@example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div v-if="resendOpen" class="modal-mask" @click.self="resendOpen = false">
|
||||
<section class="modal-card">
|
||||
<div class="modal-head">
|
||||
<h3>重发验证邮件</h3>
|
||||
<button type="button" class="modal-close" @click="resendOpen = false">关闭</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-tip">用于注册邮箱验证:请输入邮箱并完成验证码。</p>
|
||||
<p v-if="resendError" class="modal-tip error">{{ resendError }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="resend-email">邮箱</label>
|
||||
<input id="resend-email" v-model="resendForm.email" class="text-input" placeholder="name@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="resend-captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="resendForm.captcha" placeholder="请输入验证码" />
|
||||
<input
|
||||
id="resend-captcha"
|
||||
v-model="resendForm.captcha"
|
||||
class="text-input captcha-input"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<img
|
||||
v-if="resendCaptchaImage"
|
||||
class="captcha-img"
|
||||
@@ -379,16 +589,18 @@ onMounted(async () => {
|
||||
title="点击刷新"
|
||||
@click="refreshResendCaptcha"
|
||||
/>
|
||||
<el-button @click="refreshResendCaptcha">刷新</el-button>
|
||||
<button type="button" class="captcha-refresh" @click="refreshResendCaptcha">刷新</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="resendOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="resendLoading" @click="submitResend">发送</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-ghost" @click="resendOpen = false">取消</button>
|
||||
<button type="button" class="btn-login" :disabled="resendLoading" @click="submitResend">
|
||||
{{ resendLoading ? '发送中...' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -420,14 +632,14 @@ onMounted(async () => {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 60px rgba(17, 24, 39, 0.15);
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
padding: 38px 34px;
|
||||
padding: 36px 30px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.login-badge {
|
||||
@@ -454,8 +666,28 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notice.is-error {
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.notice.is-success {
|
||||
color: #065f46;
|
||||
background: #d1fae5;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
@@ -466,47 +698,66 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-input :deep(.el-input__wrapper) {
|
||||
.text-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
min-height: 44px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 0 0 1px rgba(17, 24, 39, 0.14) inset;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-input :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.7) inset, 0 0 0 4px rgba(59, 130, 246, 0.16);
|
||||
}
|
||||
|
||||
.login-input :deep(.el-input__inner) {
|
||||
border: 1px solid rgba(17, 24, 39, 0.18);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: rgba(59, 130, 246, 0.8);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.02);
|
||||
.btn-passkey {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
margin-top: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-login:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
.btn-passkey:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
.btn-passkey:disabled,
|
||||
.btn-login:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.captcha-refresh:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.action-links {
|
||||
@@ -542,15 +793,6 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.dialog-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -564,7 +806,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
height: 46px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -572,8 +814,8 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.captcha-refresh {
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
@@ -586,15 +828,100 @@ onMounted(async () => {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(560px, 96vw);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.28);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-head h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.16);
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-tip {
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-tip.warn {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.modal-tip.error {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
min-width: 86px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.2);
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-page {
|
||||
align-items: flex-start;
|
||||
padding: 20px 12px 12px;
|
||||
padding: 16px 10px 10px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
max-width: 100%;
|
||||
padding: 28px 20px;
|
||||
padding: 26px 18px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
@@ -602,18 +929,17 @@ onMounted(async () => {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
padding: 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.captcha-refresh {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,9 @@ const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const schedules = ref([])
|
||||
const schedulePage = ref(1)
|
||||
const scheduleTotal = ref(0)
|
||||
const schedulePageSize = 12
|
||||
|
||||
const accountsLoading = ref(false)
|
||||
const accountOptions = ref([])
|
||||
@@ -65,6 +68,7 @@ const weekdayOptions = [
|
||||
]
|
||||
|
||||
const canUseSchedule = computed(() => userStore.isVip)
|
||||
const scheduleTotalPages = computed(() => Math.max(1, Math.ceil((scheduleTotal.value || 0) / schedulePageSize)))
|
||||
|
||||
function normalizeTime(value) {
|
||||
const match = String(value || '').match(/^(\d{1,2}):(\d{2})$/)
|
||||
@@ -94,17 +98,37 @@ async function loadAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSchedulesAfterMutate() {
|
||||
if (schedulePage.value > 1 && schedules.value.length <= 1) {
|
||||
schedulePage.value -= 1
|
||||
}
|
||||
await loadSchedules()
|
||||
}
|
||||
|
||||
async function onSchedulePageChange(page) {
|
||||
schedulePage.value = page
|
||||
await loadSchedules()
|
||||
}
|
||||
|
||||
async function loadSchedules() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await fetchSchedules()
|
||||
schedules.value = (Array.isArray(list) ? list : []).map((s) => ({
|
||||
const params = {
|
||||
limit: schedulePageSize,
|
||||
offset: (schedulePage.value - 1) * schedulePageSize,
|
||||
}
|
||||
const payload = await fetchSchedules(params)
|
||||
const rawItems = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
|
||||
const rawTotal = Array.isArray(payload) ? rawItems.length : Number(payload?.total ?? rawItems.length)
|
||||
schedules.value = rawItems.map((s) => ({
|
||||
...s,
|
||||
browse_type: normalizeBrowseType(s?.browse_type),
|
||||
}))
|
||||
scheduleTotal.value = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : rawItems.length
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) window.location.href = '/login'
|
||||
schedules.value = []
|
||||
scheduleTotal.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -172,6 +196,7 @@ async function saveSchedule() {
|
||||
} else {
|
||||
await createSchedule(payload)
|
||||
ElMessage.success('创建成功')
|
||||
schedulePage.value = 1
|
||||
}
|
||||
editorOpen.value = false
|
||||
await loadSchedules()
|
||||
@@ -198,7 +223,7 @@ async function onDelete(schedule) {
|
||||
const res = await deleteSchedule(schedule.id)
|
||||
if (res?.success) {
|
||||
ElMessage.success('已删除')
|
||||
await loadSchedules()
|
||||
await reloadSchedulesAfterMutate()
|
||||
} else {
|
||||
ElMessage.error(res?.error || '删除失败')
|
||||
}
|
||||
@@ -375,6 +400,17 @@ onMounted(async () => {
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="scheduleTotal > schedulePageSize" class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="schedulePage"
|
||||
:page-size="schedulePageSize"
|
||||
:total="scheduleTotal"
|
||||
layout="prev, pager, next, jumper, ->, total"
|
||||
@current-change="onSchedulePageChange"
|
||||
/>
|
||||
<div class="page-hint app-muted">第 {{ schedulePage }} / {{ scheduleTotalPages }} 页</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
@@ -593,6 +629,19 @@ onMounted(async () => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { clearScreenshots, deleteScreenshot, fetchScreenshots } from '../api/screenshots'
|
||||
|
||||
const loading = ref(false)
|
||||
const screenshots = ref([])
|
||||
const currentPage = ref(1)
|
||||
const total = ref(0)
|
||||
const pageSize = 24
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
@@ -22,16 +26,30 @@ function buildThumbUrl(filename) {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchScreenshots()
|
||||
screenshots.value = Array.isArray(data) ? data : []
|
||||
const params = {
|
||||
limit: pageSize,
|
||||
offset: (currentPage.value - 1) * pageSize,
|
||||
}
|
||||
const payload = await fetchScreenshots(params)
|
||||
const items = Array.isArray(payload) ? payload : (Array.isArray(payload?.items) ? payload.items : [])
|
||||
const payloadTotal = Array.isArray(payload) ? items.length : Number(payload?.total ?? items.length)
|
||||
|
||||
screenshots.value = items
|
||||
total.value = Number.isFinite(payloadTotal) ? Math.max(0, payloadTotal) : items.length
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 401) window.location.href = '/login'
|
||||
screenshots.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPageChange(page) {
|
||||
currentPage.value = page
|
||||
await load()
|
||||
}
|
||||
|
||||
function openPreview(item) {
|
||||
previewTitle.value = item.display_name || item.filename || '截图预览'
|
||||
previewUrl.value = buildUrl(item.filename)
|
||||
@@ -126,6 +144,8 @@ async function onClearAll() {
|
||||
if (res?.success) {
|
||||
ElMessage.success(`已清空(删除 ${res?.deleted || 0} 张)`)
|
||||
screenshots.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
previewOpen.value = false
|
||||
return
|
||||
}
|
||||
@@ -150,8 +170,9 @@ async function onDelete(item) {
|
||||
try {
|
||||
const res = await deleteScreenshot(item.filename)
|
||||
if (res?.success) {
|
||||
screenshots.value = screenshots.value.filter((s) => s.filename !== item.filename)
|
||||
if (previewUrl.value.includes(encodeURIComponent(item.filename))) previewOpen.value = false
|
||||
if (currentPage.value > 1 && screenshots.value.length <= 1) currentPage.value -= 1
|
||||
await load()
|
||||
ElMessage.success('已删除')
|
||||
return
|
||||
}
|
||||
@@ -186,7 +207,7 @@ async function copyImage(item) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
}
|
||||
ElMessage.success('图片已复制到剪贴板')
|
||||
} catch (e) {
|
||||
} catch {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${url}`)
|
||||
@@ -218,13 +239,13 @@ onMounted(load)
|
||||
<div class="panel-title">截图管理</div>
|
||||
<div class="panel-actions">
|
||||
<el-button :loading="loading" @click="load">刷新</el-button>
|
||||
<el-button type="danger" plain :disabled="screenshots.length === 0" @click="onClearAll">清空全部</el-button>
|
||||
<el-button type="danger" plain :disabled="total === 0" @click="onClearAll">清空全部</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
<template v-else>
|
||||
<el-empty v-if="screenshots.length === 0" description="暂无截图" />
|
||||
<el-empty v-if="total === 0" description="暂无截图" />
|
||||
|
||||
<div v-else class="grid">
|
||||
<el-card v-for="item in screenshots" :key="item.filename" shadow="never" class="shot-card" :body-style="{ padding: '0' }">
|
||||
@@ -247,6 +268,17 @@ onMounted(load)
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, jumper, ->, total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
<div class="page-hint app-muted">第 {{ currentPage }} / {{ totalPages }} 页</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(920px, 94vw)">
|
||||
@@ -294,6 +326,19 @@ onMounted(load)
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shot-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--app-border);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import AppLayout from '../layouts/AppLayout.vue'
|
||||
|
||||
const LoginPage = () => import('../pages/LoginPage.vue')
|
||||
const RegisterPage = () => import('../pages/RegisterPage.vue')
|
||||
const ResetPasswordPage = () => import('../pages/ResetPasswordPage.vue')
|
||||
const VerifyResultPage = () => import('../pages/VerifyResultPage.vue')
|
||||
const AppLayout = () => import('../layouts/AppLayout.vue')
|
||||
|
||||
const AccountsPage = () => import('../pages/AccountsPage.vue')
|
||||
const SchedulesPage = () => import('../pages/SchedulesPage.vue')
|
||||
|
||||
123
app-frontend/src/utils/passkey.js
Normal file
123
app-frontend/src/utils/passkey.js
Normal file
@@ -0,0 +1,123 @@
|
||||
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 toRequestOptions(rawOptions) {
|
||||
const options = ensurePublicKeyOptions(rawOptions)
|
||||
const normalized = {
|
||||
...options,
|
||||
challenge: base64UrlToUint8Array(options.challenge),
|
||||
}
|
||||
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
normalized.allowCredentials = options.allowCredentials.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)
|
||||
}
|
||||
|
||||
export async function authenticateWithPasskey(rawOptions) {
|
||||
const publicKey = toRequestOptions(rawOptions)
|
||||
const credential = await navigator.credentials.get({ publicKey })
|
||||
return serializeCredential(credential)
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||
dts: false,
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
|
||||
dts: false,
|
||||
}),
|
||||
],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: '../static/app',
|
||||
@@ -11,6 +25,10 @@ export default defineConfig({
|
||||
cssCodeSplit: true,
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||
login: fileURLToPath(new URL('./login.html', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
@@ -24,10 +42,6 @@ export default defineConfig({
|
||||
return 'vendor-vue'
|
||||
}
|
||||
|
||||
if (id.includes('/node_modules/element-plus/') || id.includes('/node_modules/@element-plus/')) {
|
||||
return 'vendor-element'
|
||||
}
|
||||
|
||||
if (id.includes('/node_modules/axios/')) {
|
||||
return 'vendor-axios'
|
||||
}
|
||||
@@ -40,7 +54,7 @@ export default defineConfig({
|
||||
return 'vendor-realtime'
|
||||
}
|
||||
|
||||
return 'vendor-misc'
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
10
app.py
10
app.py
@@ -210,7 +210,15 @@ def enforce_csrf_protection():
|
||||
if request.path.startswith("/static/"):
|
||||
return
|
||||
# 登录相关路由豁免 CSRF 检查(登录本身就是建立 session 的过程)
|
||||
csrf_exempt_paths = {"/yuyx/api/login", "/api/login", "/api/auth/login"}
|
||||
csrf_exempt_paths = {
|
||||
"/yuyx/api/login",
|
||||
"/api/login",
|
||||
"/api/auth/login",
|
||||
"/yuyx/api/passkeys/login/options",
|
||||
"/yuyx/api/passkeys/login/verify",
|
||||
"/api/passkeys/login/options",
|
||||
"/api/passkeys/login/verify",
|
||||
}
|
||||
if request.path in csrf_exempt_paths:
|
||||
return
|
||||
if not (current_user.is_authenticated or "admin_id" in session):
|
||||
|
||||
13
database.py
13
database.py
@@ -25,7 +25,9 @@ from db.migrations import migrate_database as _migrate_database
|
||||
from db.admin import (
|
||||
admin_reset_user_password,
|
||||
clean_old_operation_logs,
|
||||
get_admin_by_id,
|
||||
ensure_default_admin,
|
||||
get_admin_by_username,
|
||||
get_hourly_registration_count,
|
||||
get_system_config_raw as _get_system_config_raw,
|
||||
get_system_stats,
|
||||
@@ -71,6 +73,15 @@ from db.feedbacks import (
|
||||
get_user_feedbacks,
|
||||
reply_feedback,
|
||||
)
|
||||
from db.passkeys import (
|
||||
count_passkeys,
|
||||
create_passkey,
|
||||
delete_passkey,
|
||||
get_passkey_by_credential_id,
|
||||
get_passkey_by_id,
|
||||
list_passkeys,
|
||||
update_passkey_usage,
|
||||
)
|
||||
from db.schedules import (
|
||||
clean_old_schedule_logs,
|
||||
create_schedule_execution_log,
|
||||
@@ -120,7 +131,7 @@ config = get_config()
|
||||
DB_FILE = config.DB_FILE
|
||||
|
||||
# 数据库版本 (用于迁移管理)
|
||||
DB_VERSION = 20
|
||||
DB_VERSION = 21
|
||||
|
||||
|
||||
# ==================== 系统配置缓存(P1 / O-03) ====================
|
||||
|
||||
18
db/admin.py
18
db/admin.py
@@ -165,6 +165,24 @@ def verify_admin(username: str, password: str):
|
||||
return None
|
||||
|
||||
|
||||
def get_admin_by_username(username: str):
|
||||
"""根据用户名获取管理员记录"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM admins WHERE username = ?", (username,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_admin_by_id(admin_id: int):
|
||||
"""根据ID获取管理员记录"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM admins WHERE id = ?", (int(admin_id),))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def update_admin_password(username: str, new_password: str) -> bool:
|
||||
"""更新管理员密码"""
|
||||
with db_pool.get_db() as conn:
|
||||
|
||||
@@ -75,6 +75,7 @@ def _get_migration_steps():
|
||||
(18, _migrate_to_v18),
|
||||
(19, _migrate_to_v19),
|
||||
(20, _migrate_to_v20),
|
||||
(21, _migrate_to_v21),
|
||||
]
|
||||
|
||||
|
||||
@@ -903,3 +904,32 @@ def _migrate_to_v20(conn):
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v21(conn):
|
||||
"""迁移到版本21 - Passkey 认证设备表"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_type TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
credential_id TEXT UNIQUE NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
sign_count INTEGER DEFAULT 0,
|
||||
transports TEXT DEFAULT '',
|
||||
aaguid TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner ON passkeys(owner_type, owner_id)")
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_passkeys_owner_last_used ON passkeys(owner_type, owner_id, last_used_at)"
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
173
db/passkeys.py
Normal file
173
db/passkeys.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import db_pool
|
||||
from db.utils import get_cst_now_str
|
||||
|
||||
_OWNER_TYPES = {"user", "admin"}
|
||||
|
||||
|
||||
def _normalize_owner_type(owner_type: str) -> str:
|
||||
normalized = str(owner_type or "").strip().lower()
|
||||
if normalized not in _OWNER_TYPES:
|
||||
raise ValueError(f"invalid owner_type: {owner_type}")
|
||||
return normalized
|
||||
|
||||
|
||||
def list_passkeys(owner_type: str, owner_id: int) -> list[dict]:
|
||||
owner = _normalize_owner_type(owner_type)
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, owner_type, owner_id, device_name, credential_id, transports,
|
||||
sign_count, aaguid, created_at, last_used_at
|
||||
FROM passkeys
|
||||
WHERE owner_type = ? AND owner_id = ?
|
||||
ORDER BY datetime(created_at) DESC, id DESC
|
||||
""",
|
||||
(owner, int(owner_id)),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def count_passkeys(owner_type: str, owner_id: int) -> int:
|
||||
owner = _normalize_owner_type(owner_type)
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) AS count FROM passkeys WHERE owner_type = ? AND owner_id = ?",
|
||||
(owner, int(owner_id)),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return 0
|
||||
try:
|
||||
return int(row["count"] or 0)
|
||||
except Exception:
|
||||
try:
|
||||
return int(row[0] or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_passkey_by_credential_id(credential_id: str) -> dict | None:
|
||||
credential = str(credential_id or "").strip()
|
||||
if not credential:
|
||||
return None
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, owner_type, owner_id, device_name, credential_id, public_key,
|
||||
sign_count, transports, aaguid, created_at, last_used_at
|
||||
FROM passkeys
|
||||
WHERE credential_id = ?
|
||||
""",
|
||||
(credential,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_passkey_by_id(owner_type: str, owner_id: int, passkey_id: int) -> dict | None:
|
||||
owner = _normalize_owner_type(owner_type)
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, owner_type, owner_id, device_name, credential_id, public_key,
|
||||
sign_count, transports, aaguid, created_at, last_used_at
|
||||
FROM passkeys
|
||||
WHERE id = ? AND owner_type = ? AND owner_id = ?
|
||||
""",
|
||||
(int(passkey_id), owner, int(owner_id)),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def create_passkey(
|
||||
owner_type: str,
|
||||
owner_id: int,
|
||||
*,
|
||||
credential_id: str,
|
||||
public_key: str,
|
||||
sign_count: int,
|
||||
device_name: str,
|
||||
transports: str = "",
|
||||
aaguid: str = "",
|
||||
) -> int | None:
|
||||
owner = _normalize_owner_type(owner_type)
|
||||
now = get_cst_now_str()
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO passkeys (
|
||||
owner_type,
|
||||
owner_id,
|
||||
device_name,
|
||||
credential_id,
|
||||
public_key,
|
||||
sign_count,
|
||||
transports,
|
||||
aaguid,
|
||||
created_at,
|
||||
last_used_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
owner,
|
||||
int(owner_id),
|
||||
str(device_name or "").strip(),
|
||||
str(credential_id or "").strip(),
|
||||
str(public_key or "").strip(),
|
||||
int(sign_count or 0),
|
||||
str(transports or "").strip(),
|
||||
str(aaguid or "").strip(),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cursor.lastrowid)
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
|
||||
def update_passkey_usage(passkey_id: int, new_sign_count: int) -> bool:
|
||||
now = get_cst_now_str()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE passkeys
|
||||
SET sign_count = ?,
|
||||
last_used_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(new_sign_count or 0), now, int(passkey_id)),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def delete_passkey(owner_type: str, owner_id: int, passkey_id: int) -> bool:
|
||||
owner = _normalize_owner_type(owner_type)
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"DELETE FROM passkeys WHERE id = ? AND owner_type = ? AND owner_id = ?",
|
||||
(int(passkey_id), owner, int(owner_id)),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
21
db/schema.py
21
db/schema.py
@@ -74,6 +74,25 @@ def ensure_schema(conn) -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# Passkey 认证设备表(用户/管理员)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_type TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
credential_id TEXT UNIQUE NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
sign_count INTEGER DEFAULT 0,
|
||||
transports TEXT DEFAULT '',
|
||||
aaguid TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# ==================== 安全防护:威胁检测相关表 ====================
|
||||
|
||||
# 威胁事件日志表
|
||||
@@ -368,6 +387,8 @@ def ensure_schema(conn) -> None:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status_created_at ON users(status, created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner ON passkeys(owner_type, owner_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_passkeys_owner_last_used ON passkeys(owner_type, owner_id, last_used_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
|
||||
|
||||
@@ -10,6 +10,7 @@ requests==2.31.0
|
||||
python-dotenv==1.0.0
|
||||
beautifulsoup4==4.12.2
|
||||
cryptography>=41.0.0
|
||||
webauthn>=2.7.1
|
||||
Pillow>=10.0.0
|
||||
playwright==1.42.0
|
||||
eventlet==0.36.1
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
@@ -16,6 +17,19 @@ from routes.admin_api import admin_api_bp
|
||||
from routes.decorators import admin_required
|
||||
from services.accounts_service import load_user_accounts
|
||||
from services.checkpoints import get_checkpoint_mgr
|
||||
from services.passkeys import (
|
||||
MAX_PASSKEYS_PER_OWNER,
|
||||
encode_credential_id,
|
||||
get_credential_transports,
|
||||
get_expected_origins,
|
||||
get_rp_id,
|
||||
is_challenge_valid,
|
||||
make_authentication_options,
|
||||
make_registration_options,
|
||||
normalize_device_name,
|
||||
verify_authentication,
|
||||
verify_registration,
|
||||
)
|
||||
from services.state import (
|
||||
safe_get_user_accounts_snapshot,
|
||||
safe_verify_and_consume_captcha,
|
||||
@@ -32,6 +46,8 @@ from services.tasks import submit_account_task
|
||||
|
||||
logger = get_logger("app")
|
||||
config = get_config()
|
||||
_ADMIN_PASSKEY_LOGIN_SESSION_KEY = "admin_passkey_login_state"
|
||||
_ADMIN_PASSKEY_REGISTER_SESSION_KEY = "admin_passkey_register_state"
|
||||
|
||||
|
||||
def _admin_reauth_required() -> bool:
|
||||
@@ -46,6 +62,27 @@ def _require_admin_reauth():
|
||||
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
|
||||
return None
|
||||
|
||||
|
||||
def _parse_credential_payload(data: dict) -> dict | None:
|
||||
credential = data.get("credential")
|
||||
if isinstance(credential, dict):
|
||||
return credential
|
||||
if isinstance(credential, str):
|
||||
try:
|
||||
parsed = json.loads(credential)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_text(value, max_len: int = 300) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) > max_len:
|
||||
return f"{text[:max_len]}..."
|
||||
return text
|
||||
|
||||
|
||||
@admin_api_bp.route("/debug-config", methods=["GET"])
|
||||
@admin_required
|
||||
def debug_config():
|
||||
@@ -70,6 +107,169 @@ def debug_config():
|
||||
)
|
||||
|
||||
|
||||
@admin_api_bp.route("/passkeys/login/options", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def admin_passkey_login_options():
|
||||
"""管理员 Passkey 登录:获取 assertion challenge。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = str(data.get("username", "") or "").strip()
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
mode = "named" if username else "discoverable"
|
||||
username_key = f"admin-passkey:{username}" if username else "admin-passkey:discoverable"
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "login")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
admin_id = 0
|
||||
allow_credential_ids = []
|
||||
if mode == "named":
|
||||
admin_row = database.get_admin_by_username(username)
|
||||
if not admin_row:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "账号或Passkey不可用"}), 400
|
||||
|
||||
admin_id = int(admin_row["id"])
|
||||
passkeys = database.list_passkeys("admin", admin_id)
|
||||
if not passkeys:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "该管理员尚未绑定Passkey"}), 400
|
||||
allow_credential_ids = [str(item.get("credential_id") or "").strip() for item in passkeys if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员登录 options 失败(mode={mode}, username={username or '-'}) : {e}")
|
||||
return jsonify({"error": "Passkey配置异常,请联系管理员"}), 500
|
||||
|
||||
options = make_authentication_options(rp_id=rp_id, allow_credential_ids=allow_credential_ids)
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_ADMIN_PASSKEY_LOGIN_SESSION_KEY] = {
|
||||
"mode": mode,
|
||||
"username": username,
|
||||
"admin_id": int(admin_id),
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"username_key": username_key,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options})
|
||||
|
||||
|
||||
@admin_api_bp.route("/passkeys/login/verify", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def admin_passkey_login_verify():
|
||||
"""管理员 Passkey 登录:校验 assertion 并登录。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
request_username = str(data.get("username", "") or "").strip()
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
state = session.get(_ADMIN_PASSKEY_LOGIN_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
mode = str(state.get("mode") or "named")
|
||||
if mode not in {"named", "discoverable"}:
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
|
||||
expected_username = str(state.get("username") or "").strip()
|
||||
username = expected_username
|
||||
if mode == "named":
|
||||
if not expected_username:
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
if request_username and request_username != expected_username:
|
||||
return jsonify({"error": "用户名与挑战不匹配,请重试"}), 400
|
||||
else:
|
||||
username = request_username
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
username_key = str(state.get("username_key") or "").strip() or (
|
||||
f"admin-passkey:{expected_username}" if mode == "named" else "admin-passkey:discoverable"
|
||||
)
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
credential_id = str(credential.get("id") or credential.get("rawId") or "").strip()
|
||||
if not credential_id:
|
||||
return jsonify({"error": "Passkey参数无效"}), 400
|
||||
|
||||
passkey = database.get_passkey_by_credential_id(credential_id)
|
||||
if not passkey:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不存在或已删除"}), 401
|
||||
if str(passkey.get("owner_type") or "") != "admin":
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不属于管理员账号"}), 401
|
||||
if mode == "named" and int(passkey.get("owner_id") or 0) != int(state.get("admin_id") or 0):
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey与管理员账号不匹配"}), 401
|
||||
|
||||
try:
|
||||
_, verified = verify_authentication(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
credential_public_key=str(passkey.get("public_key") or ""),
|
||||
credential_current_sign_count=int(passkey.get("sign_count") or 0),
|
||||
)
|
||||
verified_credential_id = encode_credential_id(verified.credential_id)
|
||||
if verified_credential_id != str(passkey.get("credential_id") or ""):
|
||||
raise ValueError("credential_id mismatch")
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员登录验签失败(mode={mode}, username={expected_username or request_username or '-'}) : {e}")
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey验证失败"}), 401
|
||||
|
||||
admin_id = int(passkey.get("owner_id") or 0)
|
||||
admin_row = database.get_admin_by_id(admin_id)
|
||||
if not admin_row:
|
||||
return jsonify({"error": "管理员账号不存在"}), 401
|
||||
admin_username = str(admin_row.get("username") or "").strip() or username or f"admin-{admin_id}"
|
||||
|
||||
database.update_passkey_usage(int(passkey["id"]), int(verified.new_sign_count))
|
||||
clear_login_failures(client_ip, username_key)
|
||||
admin_login_key = f"admin-passkey:{admin_username}"
|
||||
if admin_login_key and admin_login_key != username_key:
|
||||
clear_login_failures(client_ip, admin_login_key)
|
||||
|
||||
session.pop(_ADMIN_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
session.pop("admin_id", None)
|
||||
session.pop("admin_username", None)
|
||||
session["admin_id"] = admin_id
|
||||
session["admin_username"] = admin_username
|
||||
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
|
||||
session.permanent = True
|
||||
session.modified = True
|
||||
return jsonify({"success": True, "redirect": "/yuyx/admin", "username": admin_username})
|
||||
|
||||
|
||||
@admin_api_bp.route("/login", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def admin_login():
|
||||
@@ -161,6 +361,164 @@ def admin_logout():
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys", methods=["GET"])
|
||||
@admin_required
|
||||
def list_admin_passkeys():
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
rows = database.list_passkeys("admin", admin_id)
|
||||
items = []
|
||||
for row in rows:
|
||||
credential_id = str(row.get("credential_id") or "")
|
||||
preview = f"{credential_id[:8]}...{credential_id[-6:]}" if len(credential_id) > 16 else credential_id
|
||||
items.append(
|
||||
{
|
||||
"id": int(row.get("id")),
|
||||
"device_name": str(row.get("device_name") or ""),
|
||||
"credential_id_preview": preview,
|
||||
"created_at": row.get("created_at"),
|
||||
"last_used_at": row.get("last_used_at"),
|
||||
"transports": str(row.get("transports") or ""),
|
||||
}
|
||||
)
|
||||
return jsonify({"items": items, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/register/options", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_passkey_register_options():
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
admin_username = str(session.get("admin_username") or "").strip() or f"admin-{admin_id}"
|
||||
|
||||
count = database.count_passkeys("admin", admin_id)
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
device_name = normalize_device_name(data.get("device_name"))
|
||||
|
||||
existing = database.list_passkeys("admin", admin_id)
|
||||
exclude_credential_ids = [str(item.get("credential_id") or "").strip() for item in existing if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
options = make_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name="知识管理平台",
|
||||
user_name=admin_username,
|
||||
user_display_name=admin_username,
|
||||
user_id_bytes=f"admin:{admin_id}".encode("utf-8"),
|
||||
exclude_credential_ids=exclude_credential_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员注册 options 失败(admin_id={admin_id}): {e}")
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_ADMIN_PASSKEY_REGISTER_SESSION_KEY] = {
|
||||
"admin_id": admin_id,
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"device_name": device_name,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/register/verify", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_passkey_register_verify():
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
state = session.get(_ADMIN_PASSKEY_REGISTER_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if int(state.get("admin_id") or 0) != admin_id:
|
||||
return jsonify({"error": "Passkey挑战与当前管理员不匹配"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
count = database.count_passkeys("admin", admin_id)
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
try:
|
||||
verified = verify_registration(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 管理员注册验签失败(admin_id={admin_id}): {e}")
|
||||
return jsonify({"error": "Passkey验证失败,请重试"}), 400
|
||||
|
||||
device_name = normalize_device_name(data.get("device_name") if "device_name" in data else state.get("device_name"))
|
||||
|
||||
created_id = database.create_passkey(
|
||||
"admin",
|
||||
admin_id,
|
||||
credential_id=encode_credential_id(verified.credential_id),
|
||||
public_key=encode_credential_id(verified.credential_public_key),
|
||||
sign_count=int(verified.sign_count or 0),
|
||||
device_name=device_name,
|
||||
transports=get_credential_transports(credential),
|
||||
aaguid=str(verified.aaguid or ""),
|
||||
)
|
||||
if not created_id:
|
||||
return jsonify({"error": "该Passkey已绑定,或保存失败"}), 400
|
||||
|
||||
session.pop(_ADMIN_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"success": True, "id": int(created_id)})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/<int:passkey_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
def delete_admin_passkey(passkey_id):
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
ok = database.delete_passkey("admin", admin_id, int(passkey_id))
|
||||
if ok:
|
||||
return jsonify({"success": True})
|
||||
return jsonify({"error": "设备不存在或已删除"}), 404
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/passkeys/client-error", methods=["POST"])
|
||||
@admin_required
|
||||
def report_admin_passkey_client_error():
|
||||
"""上报管理员端浏览器 Passkey 失败详情,便于排查兼容性问题。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
error_name = _truncate_text(data.get("name"), 120)
|
||||
error_message = _truncate_text(data.get("message"), 400)
|
||||
error_code = _truncate_text(data.get("code"), 120)
|
||||
ua = _truncate_text(data.get("user_agent") or request.headers.get("User-Agent", ""), 300)
|
||||
stage = _truncate_text(data.get("stage"), 80)
|
||||
source = _truncate_text(data.get("source"), 80)
|
||||
admin_id = int(session.get("admin_id") or 0)
|
||||
|
||||
logger.warning(
|
||||
"[passkey][client-error][admin] admin_id=%s stage=%s source=%s name=%s code=%s message=%s ua=%s",
|
||||
admin_id,
|
||||
stage or "-",
|
||||
source or "-",
|
||||
error_name or "-",
|
||||
error_code or "-",
|
||||
error_message or "-",
|
||||
ua or "-",
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@admin_api_bp.route("/admin/reauth", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_reauth():
|
||||
|
||||
@@ -60,8 +60,8 @@ def get_kdocs_status_api():
|
||||
status = uploader.get_status()
|
||||
live = str(request.args.get("live", "")).lower() in ("1", "true", "yes")
|
||||
|
||||
# 重启后首次查询时(last_login_ok is None)自动做一次实时状态校验
|
||||
should_live_check = live or status.get("last_login_ok") is None
|
||||
# 仅在显式 live=1 时做实时状态校验,默认返回缓存状态,避免阻塞页面加载
|
||||
should_live_check = live
|
||||
if should_live_check:
|
||||
live_status = uploader.refresh_login_status()
|
||||
if live_status.get("success"):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import threading
|
||||
@@ -20,6 +21,15 @@ from flask_login import login_required, login_user, logout_user
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.accounts_service import load_user_accounts
|
||||
from services.models import User
|
||||
from services.passkeys import (
|
||||
encode_credential_id,
|
||||
get_expected_origins,
|
||||
get_rp_id,
|
||||
is_challenge_valid,
|
||||
make_authentication_options,
|
||||
normalize_device_name,
|
||||
verify_authentication,
|
||||
)
|
||||
from services.state import (
|
||||
check_ip_request_rate,
|
||||
check_email_rate_limit,
|
||||
@@ -50,6 +60,7 @@ _CAPTCHA_FONT_PATHS = [
|
||||
]
|
||||
_CAPTCHA_FONT = None
|
||||
_CAPTCHA_FONT_LOCK = threading.Lock()
|
||||
_USER_PASSKEY_LOGIN_SESSION_KEY = "user_passkey_login_state"
|
||||
|
||||
|
||||
def _get_json_payload() -> dict:
|
||||
@@ -194,6 +205,19 @@ def _send_login_security_alert_if_needed(user: dict, username: str, client_ip: s
|
||||
pass
|
||||
|
||||
|
||||
def _parse_credential_payload(data: dict) -> dict | None:
|
||||
credential = data.get("credential")
|
||||
if isinstance(credential, dict):
|
||||
return credential
|
||||
if isinstance(credential, str):
|
||||
try:
|
||||
parsed = json.loads(credential)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/register", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def register():
|
||||
@@ -538,6 +562,166 @@ def generate_captcha():
|
||||
return jsonify({"error": "验证码服务暂不可用,请联系管理员安装PIL库"}), 503
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/passkeys/login/options", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def user_passkey_login_options():
|
||||
"""用户 Passkey 登录:获取 assertion challenge。"""
|
||||
data = _get_json_payload()
|
||||
username = str(data.get("username", "") or "").strip()
|
||||
client_ip = get_rate_limit_ip()
|
||||
mode = "named" if username else "discoverable"
|
||||
username_key = f"passkey:{username}" if username else "passkey:discoverable"
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "login")
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
allowed, error_msg = check_login_rate_limits(client_ip, username_key)
|
||||
if not allowed:
|
||||
return jsonify({"error": error_msg}), 429
|
||||
|
||||
user_id = 0
|
||||
allow_credential_ids = []
|
||||
if mode == "named":
|
||||
user = database.get_user_by_username(username)
|
||||
if not user or user.get("status") != "approved":
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "账号或Passkey不可用"}), 400
|
||||
|
||||
user_id = int(user["id"])
|
||||
passkeys = database.list_passkeys("user", user_id)
|
||||
if not passkeys:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "该账号尚未绑定Passkey"}), 400
|
||||
allow_credential_ids = [str(item.get("credential_id") or "").strip() for item in passkeys if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 生成登录 challenge 失败(mode={mode}, username={username or '-'}) : {e}")
|
||||
return jsonify({"error": "Passkey配置异常,请联系管理员"}), 500
|
||||
|
||||
options = make_authentication_options(rp_id=rp_id, allow_credential_ids=allow_credential_ids)
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_USER_PASSKEY_LOGIN_SESSION_KEY] = {
|
||||
"mode": mode,
|
||||
"username": username,
|
||||
"user_id": int(user_id),
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"username_key": username_key,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options})
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/passkeys/login/verify", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def user_passkey_login_verify():
|
||||
"""用户 Passkey 登录:校验 assertion 并登录。"""
|
||||
data = _get_json_payload()
|
||||
request_username = str(data.get("username", "") or "").strip()
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
state = session.get(_USER_PASSKEY_LOGIN_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
mode = str(state.get("mode") or "named")
|
||||
if mode not in {"named", "discoverable"}:
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
|
||||
expected_username = str(state.get("username") or "").strip()
|
||||
username = expected_username
|
||||
if mode == "named":
|
||||
if not expected_username:
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey状态异常,请重试"}), 400
|
||||
if request_username and request_username != expected_username:
|
||||
return jsonify({"error": "用户名与挑战不匹配,请重试"}), 400
|
||||
else:
|
||||
username = request_username
|
||||
|
||||
client_ip = get_rate_limit_ip()
|
||||
username_key = str(state.get("username_key") or "").strip() or (
|
||||
f"passkey:{expected_username}" if mode == "named" else "passkey:discoverable"
|
||||
)
|
||||
|
||||
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
|
||||
if is_locked:
|
||||
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}秒"
|
||||
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试"}), 429
|
||||
|
||||
credential_id = str(credential.get("id") or credential.get("rawId") or "").strip()
|
||||
if not credential_id:
|
||||
return jsonify({"error": "Passkey参数无效"}), 400
|
||||
|
||||
passkey = database.get_passkey_by_credential_id(credential_id)
|
||||
if not passkey:
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不存在或已删除"}), 401
|
||||
if str(passkey.get("owner_type") or "") != "user":
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey不属于用户账号"}), 401
|
||||
if mode == "named" and int(passkey.get("owner_id") or 0) != int(state.get("user_id") or 0):
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey与账号不匹配"}), 401
|
||||
|
||||
try:
|
||||
parsed_credential, verified = verify_authentication(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
credential_public_key=str(passkey.get("public_key") or ""),
|
||||
credential_current_sign_count=int(passkey.get("sign_count") or 0),
|
||||
)
|
||||
verified_credential_id = encode_credential_id(verified.credential_id)
|
||||
if verified_credential_id != str(passkey.get("credential_id") or ""):
|
||||
raise ValueError("credential_id mismatch")
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户登录验签失败(mode={mode}, username={expected_username or request_username or '-'}) : {e}")
|
||||
record_login_failure(client_ip, username_key)
|
||||
return jsonify({"error": "Passkey验证失败"}), 401
|
||||
|
||||
user_id = int(passkey.get("owner_id") or 0)
|
||||
user = database.get_user_by_id(user_id)
|
||||
if not user or user.get("status") != "approved":
|
||||
return jsonify({"error": "账号不可用"}), 401
|
||||
|
||||
database.update_passkey_usage(int(passkey["id"]), int(verified.new_sign_count))
|
||||
clear_login_failures(client_ip, username_key)
|
||||
user_login_key = f"passkey:{str(user.get('username') or '').strip()}"
|
||||
if user_login_key and user_login_key != username_key:
|
||||
clear_login_failures(client_ip, user_login_key)
|
||||
session.pop(_USER_PASSKEY_LOGIN_SESSION_KEY, None)
|
||||
|
||||
user_obj = User(user_id)
|
||||
login_user(user_obj)
|
||||
load_user_accounts(user_id)
|
||||
|
||||
resolved_username = str(user.get("username") or "").strip() or username or f"user-{user_id}"
|
||||
_send_login_security_alert_if_needed(user=user, username=resolved_username, client_ip=client_ip)
|
||||
return jsonify({"success": True, "credential_id": parsed_credential.id, "username": resolved_username})
|
||||
|
||||
|
||||
@api_auth_bp.route("/api/login", methods=["POST"])
|
||||
@require_ip_not_locked
|
||||
def login():
|
||||
|
||||
@@ -79,6 +79,27 @@ def _parse_browse_type_or_error(raw_value, *, default=BROWSE_TYPE_SHOULD_READ):
|
||||
return browse_type, None
|
||||
|
||||
|
||||
def _parse_optional_pagination(default_limit: int = 20, *, max_limit: int = 200) -> tuple[int | None, int | None, bool]:
|
||||
limit_raw = request.args.get("limit")
|
||||
offset_raw = request.args.get("offset")
|
||||
if (limit_raw is None) and (offset_raw is None):
|
||||
return None, None, False
|
||||
|
||||
try:
|
||||
limit = int(limit_raw if limit_raw is not None else default_limit)
|
||||
except (ValueError, TypeError):
|
||||
limit = default_limit
|
||||
limit = max(1, min(limit, max_limit))
|
||||
|
||||
try:
|
||||
offset = int(offset_raw if offset_raw is not None else 0)
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
offset = max(0, offset)
|
||||
|
||||
return limit, offset, True
|
||||
|
||||
|
||||
@api_schedules_bp.route("/api/schedules", methods=["GET"])
|
||||
@login_required
|
||||
def get_user_schedules_api():
|
||||
@@ -86,6 +107,13 @@ def get_user_schedules_api():
|
||||
schedules = database.get_user_schedules(current_user.id)
|
||||
for schedule in schedules:
|
||||
schedule["account_ids"] = _parse_schedule_account_ids(schedule.get("account_ids"))
|
||||
|
||||
limit, offset, paged = _parse_optional_pagination(default_limit=12, max_limit=100)
|
||||
if paged:
|
||||
total = len(schedules)
|
||||
items = schedules[offset : offset + limit]
|
||||
return jsonify({"items": items, "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
return jsonify(schedules)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Iterator
|
||||
import database
|
||||
from app_config import get_config
|
||||
from app_security import is_safe_path
|
||||
from flask import Blueprint, jsonify, send_from_directory
|
||||
from flask import Blueprint, jsonify, request, send_from_directory
|
||||
from flask_login import current_user, login_required
|
||||
from PIL import Image, ImageOps
|
||||
from services.client_log import log_to_client
|
||||
@@ -100,6 +100,27 @@ def _remove_thumbnail(filename: str) -> None:
|
||||
os.remove(thumb_path)
|
||||
|
||||
|
||||
def _parse_optional_pagination(default_limit: int = 24, *, max_limit: int = 100) -> tuple[int | None, int | None, bool]:
|
||||
limit_raw = request.args.get("limit")
|
||||
offset_raw = request.args.get("offset")
|
||||
if (limit_raw is None) and (offset_raw is None):
|
||||
return None, None, False
|
||||
|
||||
try:
|
||||
limit = int(limit_raw if limit_raw is not None else default_limit)
|
||||
except (ValueError, TypeError):
|
||||
limit = default_limit
|
||||
limit = max(1, min(limit, max_limit))
|
||||
|
||||
try:
|
||||
offset = int(offset_raw if offset_raw is not None else 0)
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
offset = max(0, offset)
|
||||
|
||||
return limit, offset, True
|
||||
|
||||
|
||||
@api_screenshots_bp.route("/api/screenshots", methods=["GET"])
|
||||
@login_required
|
||||
def get_screenshots():
|
||||
@@ -128,6 +149,12 @@ def get_screenshots():
|
||||
for item in screenshots:
|
||||
item.pop("_created_ts", None)
|
||||
|
||||
limit, offset, paged = _parse_optional_pagination(default_limit=24, max_limit=100)
|
||||
if paged:
|
||||
total = len(screenshots)
|
||||
items = screenshots[offset : offset + limit]
|
||||
return jsonify({"items": items, "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
return jsonify(screenshots)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -2,19 +2,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import database
|
||||
import email_service
|
||||
from app_logger import get_logger
|
||||
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify, request, session
|
||||
from flask_login import current_user, login_required
|
||||
from routes.pages import render_app_spa_or_legacy
|
||||
from services.passkeys import (
|
||||
MAX_PASSKEYS_PER_OWNER,
|
||||
encode_credential_id,
|
||||
get_credential_transports,
|
||||
get_expected_origins,
|
||||
get_rp_id,
|
||||
is_challenge_valid,
|
||||
make_registration_options,
|
||||
normalize_device_name,
|
||||
verify_registration,
|
||||
)
|
||||
from services.state import check_email_rate_limit, check_ip_request_rate, safe_iter_task_status_items
|
||||
from services.tasks import get_task_scheduler
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
api_user_bp = Blueprint("api_user", __name__)
|
||||
_USER_PASSKEY_REGISTER_SESSION_KEY = "user_passkey_register_state"
|
||||
|
||||
|
||||
def _get_current_user_record():
|
||||
@@ -46,6 +61,26 @@ def _coerce_binary_flag(value, *, field_label: str):
|
||||
return value, None
|
||||
|
||||
|
||||
def _parse_credential_payload(data: dict) -> dict | None:
|
||||
credential = data.get("credential")
|
||||
if isinstance(credential, dict):
|
||||
return credential
|
||||
if isinstance(credential, str):
|
||||
try:
|
||||
parsed = json.loads(credential)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_text(value, max_len: int = 300) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) > max_len:
|
||||
return f"{text[:max_len]}..."
|
||||
return text
|
||||
|
||||
|
||||
def _check_bind_email_rate_limits(email: str):
|
||||
client_ip = get_rate_limit_ip()
|
||||
allowed, error_msg = check_ip_request_rate(client_ip, "email")
|
||||
@@ -374,6 +409,176 @@ def update_user_email_notify():
|
||||
return jsonify({"error": "更新失败"}), 500
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys", methods=["GET"])
|
||||
@login_required
|
||||
def list_user_passkeys():
|
||||
"""获取当前用户绑定的 Passkey 设备列表。"""
|
||||
rows = database.list_passkeys("user", int(current_user.id))
|
||||
items = []
|
||||
for row in rows:
|
||||
credential_id = str(row.get("credential_id") or "")
|
||||
preview = ""
|
||||
if credential_id:
|
||||
preview = f"{credential_id[:8]}...{credential_id[-6:]}" if len(credential_id) > 16 else credential_id
|
||||
items.append(
|
||||
{
|
||||
"id": int(row.get("id")),
|
||||
"device_name": str(row.get("device_name") or ""),
|
||||
"credential_id_preview": preview,
|
||||
"created_at": row.get("created_at"),
|
||||
"last_used_at": row.get("last_used_at"),
|
||||
"transports": str(row.get("transports") or ""),
|
||||
}
|
||||
)
|
||||
return jsonify({"items": items, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/register/options", methods=["POST"])
|
||||
@login_required
|
||||
def user_passkey_register_options():
|
||||
"""当前登录用户创建 Passkey:下发 registration challenge。"""
|
||||
user, error_response = _get_current_user_or_404()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
count = database.count_passkeys("user", int(current_user.id))
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
device_name = normalize_device_name(data.get("device_name"))
|
||||
|
||||
existing = database.list_passkeys("user", int(current_user.id))
|
||||
exclude_credential_ids = [str(item.get("credential_id") or "").strip() for item in existing if item.get("credential_id")]
|
||||
|
||||
try:
|
||||
rp_id = get_rp_id(request)
|
||||
expected_origins = get_expected_origins(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户注册 options 失败(user_id={current_user.id}): {e}")
|
||||
return jsonify({"error": "Passkey配置异常,请联系管理员"}), 500
|
||||
|
||||
try:
|
||||
options = make_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name="知识管理平台",
|
||||
user_name=str(user.get("username") or f"user-{current_user.id}"),
|
||||
user_display_name=str(user.get("username") or f"user-{current_user.id}"),
|
||||
user_id_bytes=f"user:{int(current_user.id)}".encode("utf-8"),
|
||||
exclude_credential_ids=exclude_credential_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户注册 options 构建失败(user_id={current_user.id}): {e}")
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
challenge = str(options.get("challenge") or "").strip()
|
||||
if not challenge:
|
||||
return jsonify({"error": "生成Passkey挑战失败"}), 500
|
||||
|
||||
session[_USER_PASSKEY_REGISTER_SESSION_KEY] = {
|
||||
"user_id": int(current_user.id),
|
||||
"challenge": challenge,
|
||||
"rp_id": rp_id,
|
||||
"expected_origins": expected_origins,
|
||||
"device_name": device_name,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
session.modified = True
|
||||
return jsonify({"publicKey": options, "limit": MAX_PASSKEYS_PER_OWNER})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/register/verify", methods=["POST"])
|
||||
@login_required
|
||||
def user_passkey_register_verify():
|
||||
"""当前登录用户创建 Passkey:校验 attestation 并落库。"""
|
||||
state = session.get(_USER_PASSKEY_REGISTER_SESSION_KEY) or {}
|
||||
if not state:
|
||||
return jsonify({"error": "Passkey挑战不存在或已过期,请重试"}), 400
|
||||
if int(state.get("user_id") or 0) != int(current_user.id):
|
||||
return jsonify({"error": "Passkey挑战与当前用户不匹配"}), 400
|
||||
if not is_challenge_valid(state.get("created_at")):
|
||||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": "Passkey挑战已过期,请重试"}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
credential = _parse_credential_payload(data)
|
||||
if not credential:
|
||||
return jsonify({"error": "Passkey参数缺失"}), 400
|
||||
|
||||
count = database.count_passkeys("user", int(current_user.id))
|
||||
if count >= MAX_PASSKEYS_PER_OWNER:
|
||||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"error": f"最多可绑定{MAX_PASSKEYS_PER_OWNER}台设备"}), 400
|
||||
|
||||
try:
|
||||
verified = verify_registration(
|
||||
credential=credential,
|
||||
expected_challenge=str(state.get("challenge") or ""),
|
||||
expected_rp_id=str(state.get("rp_id") or ""),
|
||||
expected_origins=list(state.get("expected_origins") or []),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[passkey] 用户注册验签失败(user_id={current_user.id}): {e}")
|
||||
return jsonify({"error": "Passkey验证失败,请重试"}), 400
|
||||
|
||||
credential_id = encode_credential_id(verified.credential_id)
|
||||
public_key = encode_credential_id(verified.credential_public_key)
|
||||
transports = get_credential_transports(credential)
|
||||
device_name = normalize_device_name(data.get("device_name") if "device_name" in data else state.get("device_name"))
|
||||
aaguid = str(verified.aaguid or "")
|
||||
|
||||
created_id = database.create_passkey(
|
||||
"user",
|
||||
int(current_user.id),
|
||||
credential_id=credential_id,
|
||||
public_key=public_key,
|
||||
sign_count=int(verified.sign_count or 0),
|
||||
device_name=device_name,
|
||||
transports=transports,
|
||||
aaguid=aaguid,
|
||||
)
|
||||
if not created_id:
|
||||
return jsonify({"error": "该Passkey已绑定,或保存失败"}), 400
|
||||
|
||||
session.pop(_USER_PASSKEY_REGISTER_SESSION_KEY, None)
|
||||
return jsonify({"success": True, "id": int(created_id), "device_name": device_name})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/<int:passkey_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_user_passkey(passkey_id):
|
||||
"""删除当前用户绑定的 Passkey 设备。"""
|
||||
ok = database.delete_passkey("user", int(current_user.id), int(passkey_id))
|
||||
if ok:
|
||||
return jsonify({"success": True})
|
||||
return jsonify({"error": "设备不存在或已删除"}), 404
|
||||
|
||||
|
||||
@api_user_bp.route("/api/user/passkeys/client-error", methods=["POST"])
|
||||
@login_required
|
||||
def report_user_passkey_client_error():
|
||||
"""上报浏览器端 Passkey 失败详情,便于排查兼容性问题。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
error_name = _truncate_text(data.get("name"), 120)
|
||||
error_message = _truncate_text(data.get("message"), 400)
|
||||
error_code = _truncate_text(data.get("code"), 120)
|
||||
ua = _truncate_text(data.get("user_agent") or request.headers.get("User-Agent", ""), 300)
|
||||
stage = _truncate_text(data.get("stage"), 80)
|
||||
source = _truncate_text(data.get("source"), 80)
|
||||
|
||||
logger.warning(
|
||||
"[passkey][client-error][user] user_id=%s stage=%s source=%s name=%s code=%s message=%s ua=%s",
|
||||
current_user.id,
|
||||
stage or "-",
|
||||
source or "-",
|
||||
error_name or "-",
|
||||
error_code or "-",
|
||||
error_message or "-",
|
||||
ua or "-",
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@api_user_bp.route("/api/run_stats", methods=["GET"])
|
||||
@login_required
|
||||
def get_run_stats():
|
||||
|
||||
@@ -15,10 +15,45 @@ from services.runtime import get_logger
|
||||
pages_bp = Blueprint("pages", __name__)
|
||||
|
||||
|
||||
def _collect_entry_css_files(manifest: dict, entry_name: str) -> list[str]:
|
||||
css_files: list[str] = []
|
||||
seen_css: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def _append_css(entry_obj: dict) -> None:
|
||||
for css_file in entry_obj.get("css") or []:
|
||||
css_path = str(css_file or "").strip()
|
||||
if not css_path or css_path in seen_css:
|
||||
continue
|
||||
seen_css.add(css_path)
|
||||
css_files.append(css_path)
|
||||
|
||||
def _walk_manifest_key(manifest_key: str) -> None:
|
||||
key = str(manifest_key or "").strip()
|
||||
if not key or key in visited:
|
||||
return
|
||||
visited.add(key)
|
||||
entry_obj = manifest.get(key)
|
||||
if not isinstance(entry_obj, dict):
|
||||
return
|
||||
_append_css(entry_obj)
|
||||
for imported_key in entry_obj.get("imports") or []:
|
||||
_walk_manifest_key(imported_key)
|
||||
|
||||
entry = manifest.get(entry_name) or {}
|
||||
if isinstance(entry, dict):
|
||||
_append_css(entry)
|
||||
for imported_key in entry.get("imports") or []:
|
||||
_walk_manifest_key(imported_key)
|
||||
|
||||
return css_files
|
||||
|
||||
|
||||
def render_app_spa_or_legacy(
|
||||
legacy_template_name: str,
|
||||
legacy_context: Optional[dict] = None,
|
||||
spa_initial_state: Optional[dict] = None,
|
||||
spa_entry_name: str = "index.html",
|
||||
):
|
||||
"""渲染前台 Vue SPA(构建产物位于 static/app),失败则回退旧模板。"""
|
||||
logger = get_logger()
|
||||
@@ -28,9 +63,9 @@ def render_app_spa_or_legacy(
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
entry = manifest.get("index.html") or {}
|
||||
entry = manifest.get(spa_entry_name) or {}
|
||||
js_file = entry.get("file")
|
||||
css_files = entry.get("css") or []
|
||||
css_files = _collect_entry_css_files(manifest, spa_entry_name)
|
||||
|
||||
if not js_file:
|
||||
logger.warning(f"[app_spa] manifest缺少入口文件: {manifest_path}")
|
||||
@@ -83,7 +118,7 @@ def index():
|
||||
@pages_bp.route("/login")
|
||||
def login_page():
|
||||
"""登录页面"""
|
||||
return render_app_spa_or_legacy("login.html")
|
||||
return render_app_spa_or_legacy("login.html", spa_entry_name="login.html")
|
||||
|
||||
|
||||
@pages_bp.route("/register")
|
||||
|
||||
179
services/passkeys.py
Normal file
179
services/passkeys.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Request
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers import (
|
||||
base64url_to_bytes,
|
||||
bytes_to_base64url,
|
||||
options_to_json,
|
||||
parse_authentication_credential_json,
|
||||
parse_registration_credential_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
PublicKeyCredentialDescriptor,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
MAX_PASSKEYS_PER_OWNER = 3
|
||||
CHALLENGE_TTL_SECONDS = 300
|
||||
DEVICE_NAME_MAX_LENGTH = 40
|
||||
|
||||
|
||||
def normalize_device_name(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "未命名设备"
|
||||
if len(text) > DEVICE_NAME_MAX_LENGTH:
|
||||
text = text[:DEVICE_NAME_MAX_LENGTH]
|
||||
return text
|
||||
|
||||
|
||||
def is_challenge_valid(created_at: Any, *, now_ts: float | None = None) -> bool:
|
||||
try:
|
||||
created_ts = float(created_at)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if now_ts is None:
|
||||
now_ts = time.time()
|
||||
return created_ts > 0 and (now_ts - created_ts) <= CHALLENGE_TTL_SECONDS
|
||||
|
||||
|
||||
def get_rp_id(request: Request) -> str:
|
||||
forwarded_host = str(request.headers.get("X-Forwarded-Host", "") or "").split(",", 1)[0].strip()
|
||||
host = forwarded_host or str(request.host or "").strip()
|
||||
host = host.split(":", 1)[0].strip().lower()
|
||||
if not host:
|
||||
raise ValueError("无法确定 RP ID")
|
||||
return host
|
||||
|
||||
|
||||
def get_expected_origins(request: Request) -> list[str]:
|
||||
host = str(request.host or "").strip()
|
||||
if not host:
|
||||
raise ValueError("无法确定 Origin")
|
||||
|
||||
forwarded_proto = str(request.headers.get("X-Forwarded-Proto", "") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else str(request.scheme or "https").lower()
|
||||
|
||||
origin = f"{scheme}://{host}"
|
||||
return [origin]
|
||||
|
||||
|
||||
def encode_credential_id(raw_credential_id: bytes) -> str:
|
||||
return bytes_to_base64url(raw_credential_id)
|
||||
|
||||
|
||||
def decode_credential_id(credential_id: str) -> bytes:
|
||||
return base64url_to_bytes(str(credential_id or ""))
|
||||
|
||||
|
||||
def _to_public_key_options_json(options) -> dict[str, Any]:
|
||||
return json.loads(options_to_json(options))
|
||||
|
||||
|
||||
def make_registration_options(
|
||||
*,
|
||||
rp_id: str,
|
||||
rp_name: str,
|
||||
user_name: str,
|
||||
user_display_name: str,
|
||||
user_id_bytes: bytes,
|
||||
exclude_credential_ids: list[str],
|
||||
) -> dict[str, Any]:
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=decode_credential_id(credential_id))
|
||||
for credential_id in (exclude_credential_ids or [])
|
||||
if credential_id
|
||||
]
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
user_name=user_name,
|
||||
user_display_name=user_display_name,
|
||||
user_id=user_id_bytes,
|
||||
timeout=120000,
|
||||
exclude_credentials=exclude_credentials,
|
||||
)
|
||||
return _to_public_key_options_json(options)
|
||||
|
||||
|
||||
def make_authentication_options(
|
||||
*,
|
||||
rp_id: str,
|
||||
allow_credential_ids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=decode_credential_id(credential_id))
|
||||
for credential_id in (allow_credential_ids or [])
|
||||
if credential_id
|
||||
]
|
||||
allow_credentials_value = allow_credentials if allow_credentials else None
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
timeout=120000,
|
||||
allow_credentials=allow_credentials_value,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
return _to_public_key_options_json(options)
|
||||
|
||||
|
||||
def verify_registration(
|
||||
*,
|
||||
credential: dict[str, Any],
|
||||
expected_challenge: str,
|
||||
expected_rp_id: str,
|
||||
expected_origins: list[str],
|
||||
):
|
||||
parsed = parse_registration_credential_json(credential)
|
||||
return verify_registration_response(
|
||||
credential=parsed,
|
||||
expected_challenge=base64url_to_bytes(expected_challenge),
|
||||
expected_rp_id=expected_rp_id,
|
||||
expected_origin=expected_origins,
|
||||
require_user_verification=True,
|
||||
)
|
||||
|
||||
|
||||
def verify_authentication(
|
||||
*,
|
||||
credential: dict[str, Any],
|
||||
expected_challenge: str,
|
||||
expected_rp_id: str,
|
||||
expected_origins: list[str],
|
||||
credential_public_key: str,
|
||||
credential_current_sign_count: int,
|
||||
):
|
||||
parsed = parse_authentication_credential_json(credential)
|
||||
verified = verify_authentication_response(
|
||||
credential=parsed,
|
||||
expected_challenge=base64url_to_bytes(expected_challenge),
|
||||
expected_rp_id=expected_rp_id,
|
||||
expected_origin=expected_origins,
|
||||
credential_public_key=base64url_to_bytes(credential_public_key),
|
||||
credential_current_sign_count=int(credential_current_sign_count or 0),
|
||||
require_user_verification=True,
|
||||
)
|
||||
return parsed, verified
|
||||
|
||||
|
||||
def get_credential_transports(credential: dict[str, Any]) -> str:
|
||||
response = credential.get("response") if isinstance(credential, dict) else None
|
||||
transports = response.get("transports") if isinstance(response, dict) else None
|
||||
if isinstance(transports, list):
|
||||
normalized = sorted({str(item).strip() for item in transports if str(item).strip()})
|
||||
return ",".join(normalized)
|
||||
return ""
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_MetricGrid-Dsqo4YZI.js": {
|
||||
"file": "assets/MetricGrid-Dsqo4YZI.js",
|
||||
"_MetricGrid-R-_JZS_i.js": {
|
||||
"file": "assets/MetricGrid-R-_JZS_i.js",
|
||||
"name": "MetricGrid",
|
||||
"imports": [
|
||||
"index.html",
|
||||
@@ -14,29 +14,29 @@
|
||||
"file": "assets/MetricGrid-yP_dkP6X.css",
|
||||
"src": "_MetricGrid-yP_dkP6X.css"
|
||||
},
|
||||
"_email--WygXDwI.js": {
|
||||
"file": "assets/email--WygXDwI.js",
|
||||
"_email-DX46gPSl.js": {
|
||||
"file": "assets/email-DX46gPSl.js",
|
||||
"name": "email",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_system-CAzjuaad.js": {
|
||||
"file": "assets/system-CAzjuaad.js",
|
||||
"_system-CeJP0y2Z.js": {
|
||||
"file": "assets/system-CeJP0y2Z.js",
|
||||
"name": "system",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_tasks-OWsi7T-E.js": {
|
||||
"file": "assets/tasks-OWsi7T-E.js",
|
||||
"_tasks-DaPM55hg.js": {
|
||||
"file": "assets/tasks-DaPM55hg.js",
|
||||
"name": "tasks",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_users-BZkLUJZL.js": {
|
||||
"file": "assets/users-BZkLUJZL.js",
|
||||
"_users-DoPbHko8.js": {
|
||||
"file": "assets/users-DoPbHko8.js",
|
||||
"name": "users",
|
||||
"imports": [
|
||||
"index.html"
|
||||
@@ -73,7 +73,7 @@
|
||||
"name": "vendor-vue"
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-BsqM_wut.js",
|
||||
"file": "assets/index-BMIn4N2u.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
@@ -99,7 +99,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/AnnouncementsPage.vue": {
|
||||
"file": "assets/AnnouncementsPage-PdPHO5Q2.js",
|
||||
"file": "assets/AnnouncementsPage-BY4ToZ0K.js",
|
||||
"name": "AnnouncementsPage",
|
||||
"src": "src/pages/AnnouncementsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -115,14 +115,14 @@
|
||||
]
|
||||
},
|
||||
"src/pages/EmailPage.vue": {
|
||||
"file": "assets/EmailPage-yqRvXEJ2.js",
|
||||
"file": "assets/EmailPage-DLwV2mnS.js",
|
||||
"name": "EmailPage",
|
||||
"src": "src/pages/EmailPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_email--WygXDwI.js",
|
||||
"_email-DX46gPSl.js",
|
||||
"index.html",
|
||||
"_MetricGrid-Dsqo4YZI.js",
|
||||
"_MetricGrid-R-_JZS_i.js",
|
||||
"_vendor-element-B5S5pUKo.js",
|
||||
"_vendor-vue-CVxSw_oJ.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
@@ -133,13 +133,13 @@
|
||||
]
|
||||
},
|
||||
"src/pages/FeedbacksPage.vue": {
|
||||
"file": "assets/FeedbacksPage-9Z4ULgo9.js",
|
||||
"file": "assets/FeedbacksPage-BgrVN8tx.js",
|
||||
"name": "FeedbacksPage",
|
||||
"src": "src/pages/FeedbacksPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_MetricGrid-Dsqo4YZI.js",
|
||||
"_MetricGrid-R-_JZS_i.js",
|
||||
"_vendor-element-B5S5pUKo.js",
|
||||
"_vendor-vue-CVxSw_oJ.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
@@ -150,13 +150,13 @@
|
||||
]
|
||||
},
|
||||
"src/pages/LogsPage.vue": {
|
||||
"file": "assets/LogsPage-MDq3eoIe.js",
|
||||
"file": "assets/LogsPage-D86va6oN.js",
|
||||
"name": "LogsPage",
|
||||
"src": "src/pages/LogsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-BZkLUJZL.js",
|
||||
"_tasks-OWsi7T-E.js",
|
||||
"_users-DoPbHko8.js",
|
||||
"_tasks-DaPM55hg.js",
|
||||
"index.html",
|
||||
"_vendor-element-B5S5pUKo.js",
|
||||
"_vendor-vue-CVxSw_oJ.js",
|
||||
@@ -168,17 +168,17 @@
|
||||
]
|
||||
},
|
||||
"src/pages/ReportPage.vue": {
|
||||
"file": "assets/ReportPage-ycVtg2rZ.js",
|
||||
"file": "assets/ReportPage-T1JNMZd3.js",
|
||||
"name": "ReportPage",
|
||||
"src": "src/pages/ReportPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_vendor-element-B5S5pUKo.js",
|
||||
"index.html",
|
||||
"_email--WygXDwI.js",
|
||||
"_tasks-OWsi7T-E.js",
|
||||
"_system-CAzjuaad.js",
|
||||
"_MetricGrid-Dsqo4YZI.js",
|
||||
"_email-DX46gPSl.js",
|
||||
"_tasks-DaPM55hg.js",
|
||||
"_system-CeJP0y2Z.js",
|
||||
"_MetricGrid-R-_JZS_i.js",
|
||||
"_vendor-vue-CVxSw_oJ.js",
|
||||
"_vendor-misc-BeoNyvBp.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
@@ -188,13 +188,13 @@
|
||||
]
|
||||
},
|
||||
"src/pages/SecurityPage.vue": {
|
||||
"file": "assets/SecurityPage-CXcU2SbL.js",
|
||||
"file": "assets/SecurityPage-BtFrxpZs.js",
|
||||
"name": "SecurityPage",
|
||||
"src": "src/pages/SecurityPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_MetricGrid-Dsqo4YZI.js",
|
||||
"_MetricGrid-R-_JZS_i.js",
|
||||
"_vendor-element-B5S5pUKo.js",
|
||||
"_vendor-vue-CVxSw_oJ.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
@@ -205,7 +205,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/SettingsPage.vue": {
|
||||
"file": "assets/SettingsPage-CUZAbAFF.js",
|
||||
"file": "assets/SettingsPage-BFVngq9z.js",
|
||||
"name": "SettingsPage",
|
||||
"src": "src/pages/SettingsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -217,16 +217,16 @@
|
||||
"_vendor-misc-BeoNyvBp.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/SettingsPage-NWcEVLn7.css"
|
||||
"assets/SettingsPage-qQfORNZC.css"
|
||||
]
|
||||
},
|
||||
"src/pages/SystemPage.vue": {
|
||||
"file": "assets/SystemPage-B2BrKkTP.js",
|
||||
"file": "assets/SystemPage-eaCcaVxM.js",
|
||||
"name": "SystemPage",
|
||||
"src": "src/pages/SystemPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_system-CAzjuaad.js",
|
||||
"_system-CeJP0y2Z.js",
|
||||
"index.html",
|
||||
"_vendor-element-B5S5pUKo.js",
|
||||
"_vendor-vue-CVxSw_oJ.js",
|
||||
@@ -234,16 +234,16 @@
|
||||
"_vendor-misc-BeoNyvBp.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/SystemPage-DYBocGi2.css"
|
||||
"assets/SystemPage-CfMGkvmW.css"
|
||||
]
|
||||
},
|
||||
"src/pages/UsersPage.vue": {
|
||||
"file": "assets/UsersPage-yptpHEoN.js",
|
||||
"file": "assets/UsersPage-o8CptFMp.js",
|
||||
"name": "UsersPage",
|
||||
"src": "src/pages/UsersPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-BZkLUJZL.js",
|
||||
"_users-DoPbHko8.js",
|
||||
"index.html",
|
||||
"_vendor-element-B5S5pUKo.js",
|
||||
"_vendor-vue-CVxSw_oJ.js",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{_}from"./index-BsqM_wut.js";import{aj as c,n as s,q as t,K as r,a3 as u,y as p,t as o,G as l,L as y,E as h,D as i,H as v,J as n,I as k,x as f}from"./vendor-vue-CVxSw_oJ.js";const b={class:"metric-top"},x={key:0,class:"metric-icon"},g={class:"metric-label"},B={class:"metric-value"},C={key:0,class:"metric-hint app-muted"},N={__name:"MetricGrid",props:{items:{type:Array,default:()=>[]},loading:{type:Boolean,default:!1},minWidth:{type:Number,default:180}},setup(a){return(V,D)=>{const d=c("el-icon"),m=c("el-skeleton");return t(),s("div",{class:"metric-grid",style:f({"--metric-min":`${a.minWidth}px`})},[(t(!0),s(r,null,u(a.items,e=>(t(),s("div",{key:e?.key||e?.label,class:p(["metric-card",`metric-tone--${e?.tone||"blue"}`])},[o("div",b,[e?.icon?(t(),s("div",x,[y(d,null,{default:h(()=>[(t(),i(v(e.icon)))]),_:2},1024)])):l("",!0),o("div",g,n(e?.label||"-"),1)]),o("div",B,[a.loading?(t(),i(m,{key:0,rows:1,animated:""})):(t(),s(r,{key:1},[k(n(e?.value??0),1)],64))]),e?.hint||e?.sub?(t(),s("div",C,n(e?.hint||e?.sub),1)):l("",!0)],2))),128))],4)}}},w=_(N,[["__scopeId","data-v-00e217d4"]]);export{w as M};
|
||||
import{_}from"./index-BMIn4N2u.js";import{aj as c,n as s,q as t,K as r,a3 as u,y as p,t as o,G as l,L as y,E as h,D as i,H as v,J as n,I as k,x as f}from"./vendor-vue-CVxSw_oJ.js";const b={class:"metric-top"},x={key:0,class:"metric-icon"},g={class:"metric-label"},B={class:"metric-value"},C={key:0,class:"metric-hint app-muted"},N={__name:"MetricGrid",props:{items:{type:Array,default:()=>[]},loading:{type:Boolean,default:!1},minWidth:{type:Number,default:180}},setup(a){return(V,D)=>{const d=c("el-icon"),m=c("el-skeleton");return t(),s("div",{class:"metric-grid",style:f({"--metric-min":`${a.minWidth}px`})},[(t(!0),s(r,null,u(a.items,e=>(t(),s("div",{key:e?.key||e?.label,class:p(["metric-card",`metric-tone--${e?.tone||"blue"}`])},[o("div",b,[e?.icon?(t(),s("div",x,[y(d,null,{default:h(()=>[(t(),i(v(e.icon)))]),_:2},1024)])):l("",!0),o("div",g,n(e?.label||"-"),1)]),o("div",B,[a.loading?(t(),i(m,{key:0,rows:1,animated:""})):(t(),s(r,{key:1},[k(n(e?.value??0),1)],64))]),e?.hint||e?.sub?(t(),s("div",C,n(e?.hint||e?.sub),1)):l("",!0)],2))),128))],4)}}},w=_(N,[["__scopeId","data-v-00e217d4"]]);export{w as M};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/admin/assets/SettingsPage-BFVngq9z.js
Normal file
1
static/admin/assets/SettingsPage-BFVngq9z.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{a as g,_ as T}from"./index-BsqM_wut.js";import{a as d,E as x}from"./vendor-element-B5S5pUKo.js";import{r as f,aj as w,n as S,q as U,t as m,L as a,E as s,I as P}from"./vendor-vue-CVxSw_oJ.js";import"./vendor-axios-B9ygI19o.js";import"./vendor-misc-BeoNyvBp.js";async function E(n){const{data:o}=await g.put("/admin/username",{new_username:n});return o}async function C(n={}){const o=String(n.currentPassword||""),i=String(n.newPassword||""),{data:c}=await g.put("/admin/password",{current_password:o,new_password:i});return c}async function A(){const{data:n}=await g.post("/logout");return n}const N={class:"page-stack"},I={__name:"SettingsPage",setup(n){const o=f(""),i=f(""),c=f(""),v=f(""),t=f(!1);function k(l){const e=String(l||"");return e.length<8?{ok:!1,message:"密码长度至少8位"}:e.length>128?{ok:!1,message:"密码长度不能超过128个字符"}:!/[a-zA-Z]/.test(e)||!/\d/.test(e)?{ok:!1,message:"密码必须包含字母和数字"}:{ok:!0,message:""}}async function y(){try{await A()}catch{}finally{window.location.href="/yuyx"}}async function B(){const l=o.value.trim();if(!l){d.error("请输入新用户名");return}try{await x.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}t.value=!0;try{await E(l),d.success("用户名修改成功,请重新登录"),o.value="",setTimeout(y,1200)}catch{}finally{t.value=!1}}async function h(){const l=i.value,e=c.value,p=v.value;if(!l){d.error("请输入当前密码");return}if(!e){d.error("请输入新密码");return}const r=k(e);if(!r.ok){d.error(r.message);return}if(e!==p){d.error("两次输入的新密码不一致");return}try{await x.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}t.value=!0;try{await C({currentPassword:l,newPassword:e}),d.success("密码修改成功,请重新登录"),i.value="",c.value="",v.value="",setTimeout(y,1200)}catch{}finally{t.value=!1}}return(l,e)=>{const p=w("el-input"),r=w("el-form-item"),_=w("el-form"),b=w("el-button"),V=w("el-card");return U(),S("div",N,[e[9]||(e[9]=m("div",{class:"app-page-title"},[m("h2",null,"设置"),m("span",{class:"app-muted"},"管理员账号设置")],-1)),a(V,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=m("h3",{class:"section-title"},"修改管理员用户名",-1)),a(_,{"label-width":"120px"},{default:s(()=>[a(r,{label:"新用户名"},{default:s(()=>[a(p,{modelValue:o.value,"onUpdate:modelValue":e[0]||(e[0]=u=>o.value=u),placeholder:"输入新用户名",disabled:t.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(b,{type:"primary",loading:t.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[P("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(V,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[7]||(e[7]=m("h3",{class:"section-title"},"修改管理员密码",-1)),a(_,{"label-width":"120px"},{default:s(()=>[a(r,{label:"当前密码"},{default:s(()=>[a(p,{modelValue:i.value,"onUpdate:modelValue":e[1]||(e[1]=u=>i.value=u),type:"password","show-password":"",placeholder:"输入当前密码",disabled:t.value},null,8,["modelValue","disabled"])]),_:1}),a(r,{label:"新密码"},{default:s(()=>[a(p,{modelValue:c.value,"onUpdate:modelValue":e[2]||(e[2]=u=>c.value=u),type:"password","show-password":"",placeholder:"输入新密码",disabled:t.value},null,8,["modelValue","disabled"])]),_:1}),a(r,{label:"确认新密码"},{default:s(()=>[a(p,{modelValue:v.value,"onUpdate:modelValue":e[3]||(e[3]=u=>v.value=u),type:"password","show-password":"",placeholder:"再次输入新密码",disabled:t.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(b,{type:"primary",loading:t.value,onClick:h},{default:s(()=>[...e[6]||(e[6]=[P("保存密码",-1)])]),_:1},8,["loading"]),e[8]||(e[8]=m("div",{class:"help"},"建议使用更强密码(至少8位且包含字母与数字)。",-1))]),_:1})])}}},Z=T(I,[["__scopeId","data-v-be652d2b"]]);export{Z as default};
|
||||
@@ -1 +0,0 @@
|
||||
.page-stack[data-v-be652d2b]{display:flex;flex-direction:column;gap:14px;min-width:0}.card[data-v-be652d2b]{border-radius:var(--app-radius);border:1px solid var(--app-border);background:var(--app-card-bg);box-shadow:var(--app-shadow-soft)}.section-title[data-v-be652d2b]{margin:0 0 12px;font-size:15px;font-weight:800;letter-spacing:.2px}.help[data-v-be652d2b]{margin-top:10px;font-size:12px;color:var(--app-muted)}
|
||||
1
static/admin/assets/SettingsPage-qQfORNZC.css
Normal file
1
static/admin/assets/SettingsPage-qQfORNZC.css
Normal file
@@ -0,0 +1 @@
|
||||
.page-stack[data-v-bb93be75]{display:flex;flex-direction:column;gap:14px;min-width:0}.card[data-v-bb93be75]{border-radius:var(--app-radius);border:1px solid var(--app-border);background:var(--app-card-bg);box-shadow:var(--app-shadow-soft)}.section-title[data-v-bb93be75]{margin:0 0 12px;font-size:15px;font-weight:800;letter-spacing:.2px}.help[data-v-bb93be75]{margin-top:10px;font-size:12px;color:var(--app-muted)}.help-alert[data-v-bb93be75]{margin-bottom:12px}
|
||||
File diff suppressed because one or more lines are too long
1
static/admin/assets/SystemPage-CfMGkvmW.css
Normal file
1
static/admin/assets/SystemPage-CfMGkvmW.css
Normal file
@@ -0,0 +1 @@
|
||||
.page-stack[data-v-cef111cd]{display:flex;flex-direction:column;gap:14px;min-width:0}.config-grid[data-v-cef111cd]{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card[data-v-cef111cd]{border-radius:var(--app-radius);border:1px solid var(--app-border);background:var(--app-card-bg);box-shadow:var(--app-shadow-soft)}.section-card[data-v-cef111cd]{min-width:0}.section-title[data-v-cef111cd]{margin:0;font-size:15px;font-weight:800;letter-spacing:.2px}.section-sub[data-v-cef111cd]{margin-top:6px;margin-bottom:10px;font-size:12px}.section-head[data-v-cef111cd]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:10px}.status-inline[data-v-cef111cd]{font-size:12px;display:inline-flex;align-items:center;gap:6px}.status-chip[data-v-cef111cd]{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[data-v-cef111cd]{color:#1d4ed8;background:#dbeafe;border-color:#93c5fd}.status-chip.is-online[data-v-cef111cd]{color:#065f46;background:#d1fae5;border-color:#6ee7b7}.status-chip.is-offline[data-v-cef111cd]{color:#92400e;background:#fef3c7;border-color:#fcd34d}.status-chip.is-error[data-v-cef111cd]{color:#991b1b;background:#fee2e2;border-color:#fca5a5}.status-chip.is-unknown[data-v-cef111cd]{color:#374151;background:#f3f4f6;border-color:#d1d5db}.status-dots[data-v-cef111cd]{display:inline-flex;align-items:center;gap:3px;margin-left:3px}.status-dots i[data-v-cef111cd]{width:4px;height:4px;border-radius:50%;background:currentColor;opacity:.25;animation:dotPulse-cef111cd 1.2s infinite ease-in-out}.status-dots i[data-v-cef111cd]:nth-child(2){animation-delay:.2s}.status-dots i[data-v-cef111cd]:nth-child(3){animation-delay:.4s}@keyframes dotPulse-cef111cd{0%,80%,to{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-1px)}}.kdocs-form[data-v-cef111cd]{margin-top:6px}.kdocs-inline[data-v-cef111cd]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;width:100%}.kdocs-range[data-v-cef111cd]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.kdocs-qr[data-v-cef111cd]{display:flex;flex-direction:column;align-items:center;gap:12px}.kdocs-qr img[data-v-cef111cd]{width:260px;max-width:100%;border:1px solid var(--app-border);border-radius:8px;padding:8px;background:#fff}.help[data-v-cef111cd]{margin-top:6px;font-size:12px;color:var(--app-muted)}.row-actions[data-v-cef111cd]{display:flex;flex-wrap:wrap;gap:10px}@media(max-width:1200px){.config-grid[data-v-cef111cd]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:768px){.config-grid[data-v-cef111cd],.kdocs-inline[data-v-cef111cd]{grid-template-columns:1fr}.kdocs-range[data-v-cef111cd]{align-items:stretch}}
|
||||
@@ -1 +0,0 @@
|
||||
.page-stack[data-v-a5c40f1a]{display:flex;flex-direction:column;gap:14px;min-width:0}.config-grid[data-v-a5c40f1a]{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card[data-v-a5c40f1a]{border-radius:var(--app-radius);border:1px solid var(--app-border);background:var(--app-card-bg);box-shadow:var(--app-shadow-soft)}.section-card[data-v-a5c40f1a]{min-width:0}.section-title[data-v-a5c40f1a]{margin:0;font-size:15px;font-weight:800;letter-spacing:.2px}.section-sub[data-v-a5c40f1a]{margin-top:6px;margin-bottom:10px;font-size:12px}.section-head[data-v-a5c40f1a]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:10px}.status-inline[data-v-a5c40f1a]{font-size:12px}.kdocs-form[data-v-a5c40f1a]{margin-top:6px}.kdocs-inline[data-v-a5c40f1a]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;width:100%}.kdocs-range[data-v-a5c40f1a]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.kdocs-qr[data-v-a5c40f1a]{display:flex;flex-direction:column;align-items:center;gap:12px}.kdocs-qr img[data-v-a5c40f1a]{width:260px;max-width:100%;border:1px solid var(--app-border);border-radius:8px;padding:8px;background:#fff}.help[data-v-a5c40f1a]{margin-top:6px;font-size:12px;color:var(--app-muted)}.row-actions[data-v-a5c40f1a]{display:flex;flex-wrap:wrap;gap:10px}@media(max-width:1200px){.config-grid[data-v-a5c40f1a]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:768px){.config-grid[data-v-a5c40f1a],.kdocs-inline[data-v-a5c40f1a]{grid-template-columns:1fr}.kdocs-range[data-v-a5c40f1a]{align-items:stretch}}
|
||||
6
static/admin/assets/SystemPage-eaCcaVxM.js
Normal file
6
static/admin/assets/SystemPage-eaCcaVxM.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{c as s,a as e}from"./index-BsqM_wut.js";const n=s(async()=>{const{data:a}=await e.get("/email/stats");return a},1e4);async function i(){const{data:a}=await e.get("/email/settings");return a}async function r(a){const{data:t}=await e.post("/email/settings",a);return n.clear(),t}async function o(a={}){return n.run(a)}async function l(a){const{data:t}=await e.get("/email/logs",{params:a});return t}async function u(a){const{data:t}=await e.post("/email/logs/cleanup",{days:a});return n.clear(),t}export{l as a,i as b,u as c,o as f,r as u};
|
||||
import{c as s,a as e}from"./index-BMIn4N2u.js";const n=s(async()=>{const{data:a}=await e.get("/email/stats");return a},1e4);async function i(){const{data:a}=await e.get("/email/settings");return a}async function r(a){const{data:t}=await e.post("/email/settings",a);return n.clear(),t}async function o(a={}){return n.run(a)}async function l(a){const{data:t}=await e.get("/email/logs",{params:a});return t}async function u(a){const{data:t}=await e.post("/email/logs/cleanup",{days:a});return n.clear(),t}export{l as a,i as b,u as c,o as f,r as u};
|
||||
2
static/admin/assets/index-BMIn4N2u.js
Normal file
2
static/admin/assets/index-BMIn4N2u.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{c as s,a}from"./index-BsqM_wut.js";const e=s(async()=>{const{data:t}=await a.get("/system/config");return t},15e3);async function o(t={}){return e.run(t)}async function r(t){const{data:n}=await a.post("/system/config",t);return e.clear(),n}export{o as f,r as u};
|
||||
import{c as s,a}from"./index-BMIn4N2u.js";const e=s(async()=>{const{data:t}=await a.get("/system/config");return t},15e3);async function o(t={}){return e.run(t)}async function r(t){const{data:n}=await a.post("/system/config",t);return e.clear(),n}export{o as f,r as u};
|
||||
@@ -1 +1 @@
|
||||
import{c as s,a}from"./index-BsqM_wut.js";const c=s(async()=>{const{data:t}=await a.get("/server/info");return t},3e4),o=s(async()=>{const{data:t}=await a.get("/docker_stats");return t},8e3),u=s(async()=>{const{data:t}=await a.get("/request_metrics");return t},1e4),i=s(async()=>{const{data:t}=await a.get("/slow_sql_metrics");return t},1e4),e=s(async()=>{const{data:t}=await a.get("/task/stats");return t},4e3),r=s(async()=>{const{data:t}=await a.get("/task/running");return t},2e3);async function g(t={}){return c.run(t)}async function y(t={}){return o.run(t)}async function d(t={}){return u.run(t)}async function k(t={}){return i.run(t)}async function l(t={}){return e.run(t)}async function w(t={}){return r.run(t)}async function _(t){const{data:n}=await a.get("/task/logs",{params:t});return n}async function h(t){const{data:n}=await a.post("/task/logs/clear",{days:t});return e.clear(),r.clear(),n}export{w as a,g as b,y as c,d,k as e,l as f,_ as g,h};
|
||||
import{c as s,a}from"./index-BMIn4N2u.js";const c=s(async()=>{const{data:t}=await a.get("/server/info");return t},3e4),o=s(async()=>{const{data:t}=await a.get("/docker_stats");return t},8e3),u=s(async()=>{const{data:t}=await a.get("/request_metrics");return t},1e4),i=s(async()=>{const{data:t}=await a.get("/slow_sql_metrics");return t},1e4),e=s(async()=>{const{data:t}=await a.get("/task/stats");return t},4e3),r=s(async()=>{const{data:t}=await a.get("/task/running");return t},2e3);async function g(t={}){return c.run(t)}async function y(t={}){return o.run(t)}async function d(t={}){return u.run(t)}async function k(t={}){return i.run(t)}async function l(t={}){return e.run(t)}async function w(t={}){return r.run(t)}async function _(t){const{data:n}=await a.get("/task/logs",{params:t});return n}async function h(t){const{data:n}=await a.post("/task/logs/clear",{days:t});return e.clear(),r.clear(),n}export{w as a,g as b,y as c,d,k as e,l as f,_ as g,h};
|
||||
@@ -1 +1 @@
|
||||
import{a as t}from"./index-BsqM_wut.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};
|
||||
import{a as t}from"./index-BMIn4N2u.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};
|
||||
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>后台管理 - 知识管理平台</title>
|
||||
<script type="module" crossorigin src="./assets/index-BsqM_wut.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-BMIn4N2u.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-vue-CVxSw_oJ.js">
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-misc-BeoNyvBp.js">
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-element-B5S5pUKo.js">
|
||||
|
||||
@@ -1,195 +1,384 @@
|
||||
{
|
||||
"_accounts-BtZQzP7N.js": {
|
||||
"file": "assets/accounts-BtZQzP7N.js",
|
||||
"_accounts-3bM7Wy59.js": {
|
||||
"file": "assets/accounts-3bM7Wy59.js",
|
||||
"name": "accounts",
|
||||
"imports": [
|
||||
"index.html"
|
||||
"_http-CdvgQxJu.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/accounts-D_6SYB2i.css"
|
||||
]
|
||||
},
|
||||
"_auth-BMPlNhOo.js": {
|
||||
"file": "assets/auth-BMPlNhOo.js",
|
||||
"_accounts-D_6SYB2i.css": {
|
||||
"file": "assets/accounts-D_6SYB2i.css",
|
||||
"src": "_accounts-D_6SYB2i.css"
|
||||
},
|
||||
"_auth-CX9p6ZYg.js": {
|
||||
"file": "assets/auth-CX9p6ZYg.js",
|
||||
"name": "auth",
|
||||
"imports": [
|
||||
"index.html"
|
||||
"_http-CdvgQxJu.js"
|
||||
]
|
||||
},
|
||||
"_el-alert-B-NgiIln.css": {
|
||||
"file": "assets/el-alert-B-NgiIln.css",
|
||||
"src": "_el-alert-B-NgiIln.css"
|
||||
},
|
||||
"_el-alert-DB2IQLpH.js": {
|
||||
"file": "assets/el-alert-DB2IQLpH.js",
|
||||
"name": "el-alert",
|
||||
"imports": [
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_http-CdvgQxJu.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/el-alert-B-NgiIln.css"
|
||||
]
|
||||
},
|
||||
"_el-button-DF1Fi_iE.css": {
|
||||
"file": "assets/el-button-DF1Fi_iE.css",
|
||||
"src": "_el-button-DF1Fi_iE.css"
|
||||
},
|
||||
"_el-button-DWxIvzz-.js": {
|
||||
"file": "assets/el-button-DWxIvzz-.js",
|
||||
"name": "el-button",
|
||||
"imports": [
|
||||
"_vendor-vue-DxN60LNb.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/el-button-DF1Fi_iE.css"
|
||||
]
|
||||
},
|
||||
"_el-card-BqOrgVp1.css": {
|
||||
"file": "assets/el-card-BqOrgVp1.css",
|
||||
"src": "_el-card-BqOrgVp1.css"
|
||||
},
|
||||
"_el-card-DfVpO1U5.js": {
|
||||
"file": "assets/el-card-DfVpO1U5.js",
|
||||
"name": "el-card",
|
||||
"imports": [
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_vendor-vue-DxN60LNb.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/el-card-BqOrgVp1.css"
|
||||
]
|
||||
},
|
||||
"_el-overlay-Bd56Lw6C.css": {
|
||||
"file": "assets/el-overlay-Bd56Lw6C.css",
|
||||
"src": "_el-overlay-Bd56Lw6C.css"
|
||||
},
|
||||
"_el-overlay-C_JJBVfE.js": {
|
||||
"file": "assets/el-overlay-C_JJBVfE.js",
|
||||
"name": "el-overlay",
|
||||
"imports": [
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_vendor-vue-DxN60LNb.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/el-overlay-Bd56Lw6C.css"
|
||||
]
|
||||
},
|
||||
"_el-pagination-B1FwbX1n.css": {
|
||||
"file": "assets/el-pagination-B1FwbX1n.css",
|
||||
"src": "_el-pagination-B1FwbX1n.css"
|
||||
},
|
||||
"_el-pagination-BY1uI-wO.js": {
|
||||
"file": "assets/el-pagination-BY1uI-wO.js",
|
||||
"name": "el-pagination",
|
||||
"imports": [
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_el-select-B0VMg2td.js",
|
||||
"_http-CdvgQxJu.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/el-pagination-B1FwbX1n.css"
|
||||
]
|
||||
},
|
||||
"_el-select-B0VMg2td.js": {
|
||||
"file": "assets/el-select-B0VMg2td.js",
|
||||
"name": "el-select",
|
||||
"imports": [
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_el-overlay-C_JJBVfE.js",
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_http-CdvgQxJu.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/el-select-D_oyzAZN.css"
|
||||
]
|
||||
},
|
||||
"_el-select-D_oyzAZN.css": {
|
||||
"file": "assets/el-select-D_oyzAZN.css",
|
||||
"src": "_el-select-D_oyzAZN.css"
|
||||
},
|
||||
"_http-CdvgQxJu.js": {
|
||||
"file": "assets/http-CdvgQxJu.js",
|
||||
"name": "http",
|
||||
"imports": [
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/http-D6B3r8CH.css"
|
||||
]
|
||||
},
|
||||
"_http-D6B3r8CH.css": {
|
||||
"file": "assets/http-D6B3r8CH.css",
|
||||
"src": "_http-D6B3r8CH.css"
|
||||
},
|
||||
"_isArrayLikeObject-BjIRF-cS.js": {
|
||||
"file": "assets/isArrayLikeObject-BjIRF-cS.js",
|
||||
"name": "isArrayLikeObject",
|
||||
"imports": [
|
||||
"_http-CdvgQxJu.js",
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_el-overlay-C_JJBVfE.js"
|
||||
]
|
||||
},
|
||||
"_password-7ryi82gE.js": {
|
||||
"file": "assets/password-7ryi82gE.js",
|
||||
"name": "password"
|
||||
},
|
||||
"_settings-Ddo8isuv.js": {
|
||||
"file": "assets/settings-Ddo8isuv.js",
|
||||
"name": "settings",
|
||||
"imports": [
|
||||
"_http-CdvgQxJu.js"
|
||||
]
|
||||
},
|
||||
"_style-BHGuKLUF.css": {
|
||||
"file": "assets/style-BHGuKLUF.css",
|
||||
"src": "_style-BHGuKLUF.css"
|
||||
},
|
||||
"_style-CEbARg1o.js": {
|
||||
"file": "assets/style-CEbARg1o.js",
|
||||
"name": "style",
|
||||
"css": [
|
||||
"assets/style-BHGuKLUF.css"
|
||||
]
|
||||
},
|
||||
"_user-B7bO5p8k.css": {
|
||||
"file": "assets/user-B7bO5p8k.css",
|
||||
"src": "_user-B7bO5p8k.css"
|
||||
},
|
||||
"_user-Bl59IefW.js": {
|
||||
"file": "assets/user-Bl59IefW.js",
|
||||
"name": "user",
|
||||
"imports": [
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_el-alert-DB2IQLpH.js",
|
||||
"_el-overlay-C_JJBVfE.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/user-B7bO5p8k.css"
|
||||
]
|
||||
},
|
||||
"_vendor-axios-B9ygI19o.js": {
|
||||
"file": "assets/vendor-axios-B9ygI19o.js",
|
||||
"name": "vendor-axios"
|
||||
},
|
||||
"_vendor-element-BaI2aKL6.css": {
|
||||
"file": "assets/vendor-element-BaI2aKL6.css",
|
||||
"src": "_vendor-element-BaI2aKL6.css"
|
||||
"_vendor-realtime-CA1CrNgP.js": {
|
||||
"file": "assets/vendor-realtime-CA1CrNgP.js",
|
||||
"name": "vendor-realtime"
|
||||
},
|
||||
"_vendor-element-D7IaNnTz.js": {
|
||||
"file": "assets/vendor-element-D7IaNnTz.js",
|
||||
"name": "vendor-element",
|
||||
"imports": [
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/vendor-element-BaI2aKL6.css"
|
||||
]
|
||||
},
|
||||
"_vendor-misc-0uE2ETD1.js": {
|
||||
"file": "assets/vendor-misc-0uE2ETD1.js",
|
||||
"name": "vendor-misc",
|
||||
"imports": [
|
||||
"_vendor-vue-WEaOxmRs.js"
|
||||
]
|
||||
},
|
||||
"_vendor-realtime-DJJ9FPhs.js": {
|
||||
"file": "assets/vendor-realtime-DJJ9FPhs.js",
|
||||
"name": "vendor-realtime",
|
||||
"imports": [
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
]
|
||||
},
|
||||
"_vendor-vue-WEaOxmRs.js": {
|
||||
"file": "assets/vendor-vue-WEaOxmRs.js",
|
||||
"_vendor-vue-DxN60LNb.js": {
|
||||
"file": "assets/vendor-vue-DxN60LNb.js",
|
||||
"name": "vendor-vue"
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-mJEiaIbQ.js",
|
||||
"name": "index",
|
||||
"file": "assets/app-CZnjzsIN.js",
|
||||
"name": "app",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
"_style-CEbARg1o.js",
|
||||
"_vendor-vue-DxN60LNb.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"src/pages/LoginPage.vue",
|
||||
"src/pages/RegisterPage.vue",
|
||||
"src/pages/ResetPasswordPage.vue",
|
||||
"src/pages/VerifyResultPage.vue",
|
||||
"src/layouts/AppLayout.vue",
|
||||
"src/pages/AccountsPage.vue",
|
||||
"src/pages/SchedulesPage.vue",
|
||||
"src/pages/ScreenshotsPage.vue"
|
||||
]
|
||||
},
|
||||
"login.html": {
|
||||
"file": "assets/login-BtMsx-ZC.js",
|
||||
"name": "login",
|
||||
"src": "login.html",
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"_style-CEbARg1o.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"src/pages/LoginPage.vue"
|
||||
]
|
||||
},
|
||||
"src/layouts/AppLayout.vue": {
|
||||
"file": "assets/AppLayout-Dx0be4wS.js",
|
||||
"name": "AppLayout",
|
||||
"src": "src/layouts/AppLayout.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_user-Bl59IefW.js",
|
||||
"_el-overlay-C_JJBVfE.js",
|
||||
"_el-alert-DB2IQLpH.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_settings-Ddo8isuv.js",
|
||||
"_password-7ryi82gE.js",
|
||||
"_style-CEbARg1o.js",
|
||||
"_isArrayLikeObject-BjIRF-cS.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/index-BJUdh4ps.css"
|
||||
"assets/AppLayout-D94213-a.css"
|
||||
]
|
||||
},
|
||||
"src/pages/AccountsPage.vue": {
|
||||
"file": "assets/AccountsPage-DWpwj4Fi.js",
|
||||
"file": "assets/AccountsPage-DnOxRP7e.js",
|
||||
"name": "AccountsPage",
|
||||
"src": "src/pages/AccountsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_accounts-BtZQzP7N.js",
|
||||
"index.html",
|
||||
"_vendor-realtime-DJJ9FPhs.js",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_el-overlay-C_JJBVfE.js",
|
||||
"_el-alert-DB2IQLpH.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_user-Bl59IefW.js",
|
||||
"_accounts-3bM7Wy59.js",
|
||||
"_el-select-B0VMg2td.js",
|
||||
"_el-card-DfVpO1U5.js",
|
||||
"_settings-Ddo8isuv.js",
|
||||
"_vendor-realtime-CA1CrNgP.js",
|
||||
"_style-CEbARg1o.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/AccountsPage-CRlBbogn.css"
|
||||
"assets/AccountsPage-iiBFNme8.css"
|
||||
]
|
||||
},
|
||||
"src/pages/LoginPage.vue": {
|
||||
"file": "assets/LoginPage-DECcLiBH.js",
|
||||
"file": "assets/LoginPage-D5iXLq7p.js",
|
||||
"name": "LoginPage",
|
||||
"src": "src/pages/LoginPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_auth-BMPlNhOo.js",
|
||||
"index.html",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_style-CEbARg1o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/LoginPage-C2MRCnlU.css"
|
||||
"assets/LoginPage-DTj5KeC4.css"
|
||||
]
|
||||
},
|
||||
"src/pages/RegisterPage.vue": {
|
||||
"file": "assets/RegisterPage-BMX0En46.js",
|
||||
"file": "assets/RegisterPage-4xFnBJCQ.js",
|
||||
"name": "RegisterPage",
|
||||
"src": "src/pages/RegisterPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_auth-BMPlNhOo.js",
|
||||
"index.html",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_el-card-DfVpO1U5.js",
|
||||
"_el-alert-DB2IQLpH.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_auth-CX9p6ZYg.js",
|
||||
"_password-7ryi82gE.js",
|
||||
"_style-CEbARg1o.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/RegisterPage-BOcNcW5D.css"
|
||||
]
|
||||
},
|
||||
"src/pages/ResetPasswordPage.vue": {
|
||||
"file": "assets/ResetPasswordPage-Dmc9OJGd.js",
|
||||
"file": "assets/ResetPasswordPage-lX7l6Nbu.js",
|
||||
"name": "ResetPasswordPage",
|
||||
"src": "src/pages/ResetPasswordPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_auth-BMPlNhOo.js",
|
||||
"index.html",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_el-card-DfVpO1U5.js",
|
||||
"_el-alert-DB2IQLpH.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_auth-CX9p6ZYg.js",
|
||||
"_password-7ryi82gE.js",
|
||||
"_style-CEbARg1o.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/ResetPasswordPage-DybfLMAw.css"
|
||||
]
|
||||
},
|
||||
"src/pages/SchedulesPage.vue": {
|
||||
"file": "assets/SchedulesPage-C4jkMdDz.js",
|
||||
"file": "assets/SchedulesPage-TUv7nqYq.js",
|
||||
"name": "SchedulesPage",
|
||||
"src": "src/pages/SchedulesPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_accounts-BtZQzP7N.js",
|
||||
"index.html",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_el-overlay-C_JJBVfE.js",
|
||||
"_el-alert-DB2IQLpH.js",
|
||||
"_el-select-B0VMg2td.js",
|
||||
"_user-Bl59IefW.js",
|
||||
"_accounts-3bM7Wy59.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_el-pagination-BY1uI-wO.js",
|
||||
"_el-card-DfVpO1U5.js",
|
||||
"_style-CEbARg1o.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_isArrayLikeObject-BjIRF-cS.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/SchedulesPage-DwfusXou.css"
|
||||
"assets/SchedulesPage-BIuHs5oJ.css"
|
||||
]
|
||||
},
|
||||
"src/pages/ScreenshotsPage.vue": {
|
||||
"file": "assets/ScreenshotsPage-yAHecmT2.js",
|
||||
"file": "assets/ScreenshotsPage-7CRd3Hlo.js",
|
||||
"name": "ScreenshotsPage",
|
||||
"src": "src/pages/ScreenshotsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"_vendor-axios-B9ygI19o.js",
|
||||
"_vendor-misc-0uE2ETD1.js"
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_el-overlay-C_JJBVfE.js",
|
||||
"_el-pagination-BY1uI-wO.js",
|
||||
"_el-select-B0VMg2td.js",
|
||||
"_http-CdvgQxJu.js",
|
||||
"_el-card-DfVpO1U5.js",
|
||||
"_style-CEbARg1o.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/ScreenshotsPage-ByqUbmUI.css"
|
||||
"assets/ScreenshotsPage-30dzddw-.css"
|
||||
]
|
||||
},
|
||||
"src/pages/VerifyResultPage.vue": {
|
||||
"file": "assets/VerifyResultPage-CHa6D86j.js",
|
||||
"file": "assets/VerifyResultPage-bifpPyoE.js",
|
||||
"name": "VerifyResultPage",
|
||||
"src": "src/pages/VerifyResultPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_vendor-vue-WEaOxmRs.js",
|
||||
"index.html",
|
||||
"_vendor-element-D7IaNnTz.js",
|
||||
"_vendor-misc-0uE2ETD1.js",
|
||||
"_vendor-axios-B9ygI19o.js"
|
||||
"_el-button-DWxIvzz-.js",
|
||||
"_el-card-DfVpO1U5.js",
|
||||
"_vendor-vue-DxN60LNb.js",
|
||||
"_style-CEbARg1o.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/VerifyResultPage-CG6ZYNrm.css"
|
||||
"assets/VerifyResultPage-efSXaaKI.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
.page[data-v-a3d05837]{display:flex;flex-direction:column;gap:12px}.stat-card[data-v-a3d05837],.panel[data-v-a3d05837]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-label[data-v-a3d05837]{font-size:12px}.stat-value[data-v-a3d05837]{margin-top:6px;font-size:22px;font-weight:900;letter-spacing:.2px}.stat-suffix[data-v-a3d05837]{margin-left:6px;font-size:12px;font-weight:600}.upgrade-banner[data-v-a3d05837]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.upgrade-actions[data-v-a3d05837]{margin-top:10px}.panel-head[data-v-a3d05837]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title[data-v-a3d05837]{font-size:16px;font-weight:900}.panel-actions[data-v-a3d05837]{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.toolbar[data-v-a3d05837]{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:10px;border:1px dashed rgba(17,24,39,.14);border-radius:12px;background:#f6f7fb99}.toolbar-left[data-v-a3d05837],.toolbar-middle[data-v-a3d05837],.toolbar-right[data-v-a3d05837]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.toolbar-right[data-v-a3d05837]{margin-left:auto;justify-content:flex-end}.grid[data-v-a3d05837]{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;align-items:start}.account-card[data-v-a3d05837]{border-radius:14px;border:1px solid var(--app-border)}.card-top[data-v-a3d05837]{display:flex;gap:10px}.card-check[data-v-a3d05837]{padding-top:2px}.card-main[data-v-a3d05837]{min-width:0;flex:1}.card-title[data-v-a3d05837]{display:flex;align-items:center;justify-content:space-between;gap:10px}.card-name[data-v-a3d05837]{font-size:14px;font-weight:900;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-sub[data-v-a3d05837]{margin-top:6px;font-size:12px;line-height:1.4;word-break:break-word}.progress[data-v-a3d05837]{margin-top:12px}.progress-meta[data-v-a3d05837]{margin-top:6px;display:flex;justify-content:space-between;gap:10px;font-size:12px}.card-controls[data-v-a3d05837]{margin-top:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.card-buttons[data-v-a3d05837]{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}.vip-body[data-v-a3d05837]{padding:12px 0 0}.vip-tip[data-v-a3d05837]{margin-top:10px;font-size:13px;line-height:1.6}@media(max-width:480px){.grid[data-v-a3d05837]{grid-template-columns:1fr}}@media(max-width:768px){.panel-actions[data-v-a3d05837]{width:100%;justify-content:flex-end}.toolbar-left[data-v-a3d05837],.toolbar-middle[data-v-a3d05837],.toolbar-right[data-v-a3d05837]{width:100%}.toolbar-right[data-v-a3d05837]{margin-left:0;justify-content:flex-end}}
|
||||
File diff suppressed because one or more lines are too long
6
static/app/assets/AccountsPage-DnOxRP7e.js
Normal file
6
static/app/assets/AccountsPage-DnOxRP7e.js
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/AccountsPage-iiBFNme8.css
Normal file
1
static/app/assets/AccountsPage-iiBFNme8.css
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/AppLayout-D94213-a.css
Normal file
1
static/app/assets/AppLayout-D94213-a.css
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/AppLayout-Dx0be4wS.js
Normal file
1
static/app/assets/AppLayout-Dx0be4wS.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.login-page[data-v-15383fb6]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;position:relative;background:linear-gradient(135deg,#eef2ff,#f6f7fb 45%,#ecfeff)}.login-page[data-v-15383fb6]:before{content:"";position:fixed;inset:0;background:radial-gradient(800px 500px at 15% 20%,rgba(59,130,246,.18),transparent 60%),radial-gradient(700px 420px at 85% 70%,rgba(124,58,237,.16),transparent 55%);pointer-events:none}.login-container[data-v-15383fb6]{width:100%;max-width:420px;background:#fff;border-radius:16px;box-shadow:0 18px 60px #11182726;border:1px solid rgba(17,24,39,.08);padding:38px 34px;position:relative;z-index:1}.login-header[data-v-15383fb6]{text-align:center;margin-bottom:28px}.login-badge[data-v-15383fb6]{display:inline-block;background:#3b82f61a;color:#1d4ed8;padding:6px 14px;border-radius:999px;font-size:12px;font-weight:700;margin-bottom:14px}.login-header h1[data-v-15383fb6]{font-size:24px;color:#111827;margin:0 0 10px;letter-spacing:.2px}.login-header p[data-v-15383fb6]{margin:0;color:#6b7280;font-size:14px}.form-group[data-v-15383fb6]{margin-bottom:20px}.form-group label[data-v-15383fb6]{display:block;margin-bottom:8px;color:#111827;font-weight:700;font-size:13px}.login-input[data-v-15383fb6] .el-input__wrapper{border-radius:10px;min-height:44px;background:#ffffffe6;box-shadow:0 0 0 1px #11182724 inset;transition:box-shadow .2s}.login-input[data-v-15383fb6] .el-input__wrapper.is-focus{box-shadow:0 0 0 1px #3b82f6b3 inset,0 0 0 4px #3b82f629}.login-input[data-v-15383fb6] .el-input__inner{font-size:14px}.btn-login[data-v-15383fb6]{width:100%;padding:12px;border:none;border-radius:10px;background:linear-gradient(135deg,#2563eb,#7c3aed);color:#fff;font-size:16px;font-weight:800;cursor:pointer;transition:transform .15s,filter .15s}.btn-login[data-v-15383fb6]:hover:not(:disabled){transform:translateY(-2px);filter:brightness(1.02)}.btn-login[data-v-15383fb6]:active:not(:disabled){transform:translateY(0)}.btn-login[data-v-15383fb6]:disabled{cursor:not-allowed;opacity:.8}.action-links[data-v-15383fb6]{margin-top:14px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.link-btn[data-v-15383fb6]{border:none;background:none;color:#2563eb;font-size:13px;font-weight:700;cursor:pointer;padding:0}.link-btn[data-v-15383fb6]:hover{text-decoration:underline}.register-row[data-v-15383fb6]{margin-top:16px;display:flex;justify-content:center;align-items:center;gap:8px;color:#6b7280;font-size:13px}.dialog-form[data-v-15383fb6]{margin-top:10px}.alert[data-v-15383fb6]{margin-top:12px}.captcha-row[data-v-15383fb6]{display:flex;align-items:center;gap:10px;width:100%}.captcha-input[data-v-15383fb6]{flex:1;min-width:0}.captcha-img[data-v-15383fb6]{height:46px;border:1px solid rgba(17,24,39,.14);border-radius:8px;cursor:pointer;-webkit-user-select:none;user-select:none}.captcha-refresh[data-v-15383fb6]{height:44px;padding:0 14px;border:1px solid rgba(17,24,39,.14);border-radius:10px;background:#f8fafc;color:#111827;font-size:13px;cursor:pointer}.captcha-refresh[data-v-15383fb6]:hover{background:#f1f5f9}@media(max-width:480px){.login-page[data-v-15383fb6]{align-items:flex-start;padding:20px 12px 12px}.login-container[data-v-15383fb6]{max-width:100%;padding:28px 20px;border-radius:14px}.login-header h1[data-v-15383fb6]{font-size:22px}.btn-login[data-v-15383fb6]{padding:13px;font-size:15px}.captcha-img[data-v-15383fb6]{height:42px}.captcha-refresh[data-v-15383fb6]{height:42px;padding:0 12px}}
|
||||
1
static/app/assets/LoginPage-D5iXLq7p.js
Normal file
1
static/app/assets/LoginPage-D5iXLq7p.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/app/assets/LoginPage-DTj5KeC4.css
Normal file
1
static/app/assets/LoginPage-DTj5KeC4.css
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/RegisterPage-4xFnBJCQ.js
Normal file
1
static/app/assets/RegisterPage-4xFnBJCQ.js
Normal file
@@ -0,0 +1 @@
|
||||
import{E as D}from"./el-button-DWxIvzz-.js";import{E as F}from"./el-card-DfVpO1U5.js";import{E as L,a as M,b as j}from"./el-alert-DB2IQLpH.js";import{E as q,a as c}from"./http-CdvgQxJu.js";import{f as H,g as d,h as B,i as z,j as S,q as s,s as o,u as G,o as g,k as n,c as U,l as C,m as I,t as J,x}from"./vendor-vue-DxN60LNb.js";import{g as O,f as Q,r as W}from"./auth-CX9p6ZYg.js";import{v as X}from"./password-7ryi82gE.js";import{_ as Y}from"./style-CEbARg1o.js";import"./vendor-axios-B9ygI19o.js";const Z={class:"auth-wrap"},$={class:"hint app-muted"},ee={class:"captcha-row"},ae=["src"],te={class:"actions"},se={__name:"RegisterPage",setup(le){const N=G(),a=H({username:"",password:"",confirm_password:"",email:"",captcha:""}),f=d(!1),v=d(""),h=d(""),b=d(!1),t=d(""),w=d(""),V=d(""),P=B(()=>f.value?"邮箱 *":"邮箱(可选)"),T=B(()=>f.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function _(){try{const u=await O();h.value=u?.session_id||"",v.value=u?.captcha_image||"",a.captcha=""}catch{h.value="",v.value=""}}async function K(){try{const u=await Q();f.value=!!u?.register_verify_enabled}catch{f.value=!1}}function R(){t.value="",w.value="",V.value=""}async function E(){R();const u=a.username.trim(),e=a.password,y=a.confirm_password,l=a.email.trim(),i=a.captcha.trim();if(u.length<3){t.value="用户名至少3个字符",c.error(t.value);return}const p=X(e);if(!p.ok){t.value=p.message||"密码格式不正确",c.error(t.value);return}if(e!==y){t.value="两次输入的密码不一致",c.error(t.value);return}if(f.value&&!l){t.value="请填写邮箱地址用于账号验证",c.error(t.value);return}if(l&&!l.includes("@")){t.value="邮箱格式不正确",c.error(t.value);return}if(!i){t.value="请输入验证码",c.error(t.value);return}b.value=!0;try{const m=await W({username:u,password:e,email:l,captcha_session:h.value,captcha:i});w.value=m?.message||"注册成功",V.value=m?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",c.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(m){const k=m?.response?.data;t.value=k?.error||"注册失败",c.error(t.value),await _()}finally{b.value=!1}}function A(){N.push("/login")}return z(async()=>{await _(),await K()}),(u,e)=>{const y=L,l=q,i=j,p=D,m=M,k=F;return g(),S("div",Z,[s(k,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),t.value?(g(),U(y,{key:0,type:"error",closable:!1,title:t.value,"show-icon":"",class:"alert"},null,8,["title"])):C("",!0),w.value?(g(),U(y,{key:1,type:"success",closable:!1,title:w.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):C("",!0),s(m,{"label-position":"top"},{default:o(()=>[s(i,{label:"用户名 *"},{default:o(()=>[s(l,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),s(i,{label:"密码 *"},{default:o(()=>[s(l,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少8位且包含字母和数字",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少8位且包含字母和数字",-1))]),_:1}),s(i,{label:"确认密码 *"},{default:o(()=>[s(l,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:I(E,["enter"])},null,8,["modelValue"])]),_:1}),s(i,{label:P.value},{default:o(()=>[s(l,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",$,J(T.value),1)]),_:1},8,["label"]),s(i,{label:"验证码 *"},{default:o(()=>[n("div",ee,[s(l,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:I(E,["enter"])},null,8,["modelValue"]),v.value?(g(),S("img",{key:0,class:"captcha-img",src:v.value,alt:"验证码",title:"点击刷新",onClick:_},null,8,ae)):C("",!0),s(p,{onClick:_},{default:o(()=>[...e[7]||(e[7]=[x("刷新",-1)])]),_:1})])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:b.value,onClick:E},{default:o(()=>[...e[8]||(e[8]=[x("注册",-1)])]),_:1},8,["loading"]),n("div",te,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),s(p,{link:"",type:"primary",onClick:A},{default:o(()=>[...e[9]||(e[9]=[x("立即登录",-1)])]),_:1})])]),_:1})])}}},fe=Y(se,[["__scopeId","data-v-a9d7804f"]]);export{fe as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{S as L,r as d,c as B,o as M,n as U,K as l,D as o,aj as v,az as j,q as b,t as n,C as K,F as S,ae as N,I as q,H as E}from"./vendor-vue-WEaOxmRs.js";import{g as z,f as A,b as F}from"./auth-BMPlNhOo.js";import{_ as G,v as J}from"./index-mJEiaIbQ.js";import{E as c}from"./vendor-element-D7IaNnTz.js";import"./vendor-axios-B9ygI19o.js";import"./vendor-misc-0uE2ETD1.js";const O={class:"auth-wrap"},Q={class:"hint app-muted"},W={class:"captcha-row"},X=["src"],Y={class:"actions"},Z={__name:"RegisterPage",setup($){const P=j(),a=L({username:"",password:"",confirm_password:"",email:"",captcha:""}),f=d(!1),w=d(""),h=d(""),V=d(!1),t=d(""),_=d(""),k=d(""),T=B(()=>f.value?"邮箱 *":"邮箱(可选)"),D=B(()=>f.value?"必填,用于账号验证":"选填,用于找回密码和接收通知");async function y(){try{const u=await z();h.value=u?.session_id||"",w.value=u?.captcha_image||"",a.captcha=""}catch{h.value="",w.value=""}}async function I(){try{const u=await A();f.value=!!u?.register_verify_enabled}catch{f.value=!1}}function R(){t.value="",_.value="",k.value=""}async function C(){R();const u=a.username.trim(),e=a.password,g=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){t.value="用户名至少3个字符",c.error(t.value);return}const p=J(e);if(!p.ok){t.value=p.message||"密码格式不正确",c.error(t.value);return}if(e!==g){t.value="两次输入的密码不一致",c.error(t.value);return}if(f.value&&!s){t.value="请填写邮箱地址用于账号验证",c.error(t.value);return}if(s&&!s.includes("@")){t.value="邮箱格式不正确",c.error(t.value);return}if(!i){t.value="请输入验证码",c.error(t.value);return}V.value=!0;try{const m=await F({username:u,password:e,email:s,captcha_session:h.value,captcha:i});_.value=m?.message||"注册成功",k.value=m?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",c.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(m){const x=m?.response?.data;t.value=x?.error||"注册失败",c.error(t.value),await y()}finally{V.value=!1}}function H(){P.push("/login")}return M(async()=>{await y(),await I()}),(u,e)=>{const g=v("el-alert"),s=v("el-input"),i=v("el-form-item"),p=v("el-button"),m=v("el-form"),x=v("el-card");return b(),U("div",O,[l(x,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),t.value?(b(),K(g,{key:0,type:"error",closable:!1,title:t.value,"show-icon":"",class:"alert"},null,8,["title"])):S("",!0),_.value?(b(),K(g,{key:1,type:"success",closable:!1,title:_.value,description:k.value,"show-icon":"",class:"alert"},null,8,["title","description"])):S("",!0),l(m,{"label-position":"top"},{default:o(()=>[l(i,{label:"用户名 *"},{default:o(()=>[l(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),l(i,{label:"密码 *"},{default:o(()=>[l(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少8位且包含字母和数字",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少8位且包含字母和数字",-1))]),_:1}),l(i,{label:"确认密码 *"},{default:o(()=>[l(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(C,["enter"])},null,8,["modelValue"])]),_:1}),l(i,{label:T.value},{default:o(()=>[l(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",Q,q(D.value),1)]),_:1},8,["label"]),l(i,{label:"验证码 *"},{default:o(()=>[n("div",W,[l(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(C,["enter"])},null,8,["modelValue"]),w.value?(b(),U("img",{key:0,class:"captcha-img",src:w.value,alt:"验证码",title:"点击刷新",onClick:y},null,8,X)):S("",!0),l(p,{onClick:y},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),l(p,{type:"primary",class:"submit-btn",loading:V.value,onClick:C},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",Y,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),l(p,{link:"",type:"primary",onClick:H},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},re=G(Z,[["__scopeId","data-v-a9d7804f"]]);export{re as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{r as n,ay as K,S as L,c as M,o as U,R as j,n as v,K as s,D as a,aj as l,az as D,q as m,t as w,J as h,H as k,C as F,F as x,ae as q,I as z}from"./vendor-vue-WEaOxmRs.js";import{c as H}from"./auth-BMPlNhOo.js";import{_ as J,v as G}from"./index-mJEiaIbQ.js";import{E as y}from"./vendor-element-D7IaNnTz.js";import"./vendor-axios-B9ygI19o.js";import"./vendor-misc-0uE2ETD1.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=K(),C=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=L({newPassword:"",confirmPassword:""}),g=n(!1),f=n(""),d=n(0);let u=null;function R(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=M(()=>!!(i.value&&r.value&&!f.value));function S(){C.push("/login")}function A(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=G(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await H({token:r.value,new_password:o}),f.value="密码重置成功!3秒后跳转到登录页面...",y.success("密码重置成功"),A()}catch(p){const _=p?.response?.data;y.error(_?.error||"重置失败")}finally{g.value=!1}}return U(()=>{const o=R();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),j(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),_=l("el-input"),T=l("el-form-item"),N=l("el-form"),E=l("el-card");return m(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(m(),v(h,{key:1},[f.value?(m(),F(c,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(N,{"label-position":"top"},{default:a(()=>[s(T,{label:"新密码(至少8位且包含字母和数字)"},{default:a(()=>[s(_,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(T,{label:"确认密码"},{default:a(()=>[s(_,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:q(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(m(),v("span",X,z(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(m(),v(h,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},ne=J(Y,[["__scopeId","data-v-0bbb511c"]]);export{ne as default};
|
||||
1
static/app/assets/ResetPasswordPage-lX7l6Nbu.js
Normal file
1
static/app/assets/ResetPasswordPage-lX7l6Nbu.js
Normal file
@@ -0,0 +1 @@
|
||||
import{E as R}from"./el-button-DWxIvzz-.js";import{E as F}from"./el-card-DfVpO1U5.js";import{E as L,a as M,b as U}from"./el-alert-DB2IQLpH.js";import{E as j,a as _}from"./http-CdvgQxJu.js";import{g as n,y as K,f as q,h as z,i as D,z as G,j as v,q as s,s as a,u as H,o as p,k as m,F as V,x as P,c as J,l as h,m as O,t as Q}from"./vendor-vue-DxN60LNb.js";import{c as W}from"./auth-CX9p6ZYg.js";import{v as X}from"./password-7ryi82gE.js";import{_ as Y}from"./style-CEbARg1o.js";import"./vendor-axios-B9ygI19o.js";const Z={class:"auth-wrap"},$={class:"actions"},ee={class:"actions"},oe={key:0,class:"app-muted"},se={__name:"ResetPasswordPage",setup(ae){const T=K(),x=H(),l=n(String(T.params.token||"")),r=n(!0),y=n(""),t=q({newPassword:"",confirmPassword:""}),b=n(!1),f=n(""),i=n(0);let d=null;function B(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const k=z(()=>!!(r.value&&l.value&&!f.value));function E(){x.push("/login")}function A(){i.value=3,d=window.setInterval(()=>{i.value-=1,i.value<=0&&(window.clearInterval(d),d=null,window.location.href="/login")},1e3)}async function I(){if(!k.value)return;const o=t.newPassword,e=t.confirmPassword,u=X(o);if(!u.ok){_.error(u.message);return}if(o!==e){_.error("两次输入的密码不一致");return}b.value=!0;try{await W({token:l.value,new_password:o}),f.value="密码重置成功!3秒后跳转到登录页面...",_.success("密码重置成功"),A()}catch(c){const w=c?.response?.data;_.error(w?.error||"重置失败")}finally{b.value=!1}}return D(()=>{const o=B();o?.page==="reset_password"?(l.value=String(o?.token||l.value||""),r.value=!!o?.valid,y.value=o?.error_message||(r.value?"":"重置链接无效或已过期,请重新申请密码重置")):l.value||(r.value=!1,y.value="重置链接无效或已过期,请重新申请密码重置")}),G(()=>{d&&window.clearInterval(d)}),(o,e)=>{const u=L,c=R,w=j,S=U,C=M,N=F;return p(),v("div",Z,[s(N,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=m("div",{class:"brand"},[m("div",{class:"brand-title"},"知识管理平台"),m("div",{class:"brand-sub app-muted"},"重置密码")],-1)),r.value?(p(),v(V,{key:1},[f.value?(p(),J(u,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):h("",!0),s(C,{"label-position":"top"},{default:a(()=>[s(S,{label:"新密码(至少8位且包含字母和数字)"},{default:a(()=>[s(w,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=g=>t.newPassword=g),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(S,{label:"确认密码"},{default:a(()=>[s(w,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=g=>t.confirmPassword=g),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:O(I,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(c,{type:"primary",class:"submit-btn",loading:b.value,disabled:!k.value,onClick:I},{default:a(()=>[...e[3]||(e[3]=[P(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),m("div",ee,[s(c,{link:"",type:"primary",onClick:E},{default:a(()=>[...e[4]||(e[4]=[P("返回登录",-1)])]),_:1}),i.value>0?(p(),v("span",oe,Q(i.value)+" 秒后自动跳转…",1)):h("",!0)])],64)):(p(),v(V,{key:0},[s(u,{type:"error",closable:!1,title:"链接已失效",description:y.value,"show-icon":""},null,8,["description"]),m("div",$,[s(c,{type:"primary",onClick:E},{default:a(()=>[...e[2]||(e[2]=[P("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},me=Y(se,[["__scopeId","data-v-0bbb511c"]]);export{me as default};
|
||||
1
static/app/assets/SchedulesPage-BIuHs5oJ.css
Normal file
1
static/app/assets/SchedulesPage-BIuHs5oJ.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.page[data-v-e7d93ff5]{display:flex;flex-direction:column;gap:12px}.switch-row[data-v-e7d93ff5]{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.vip-alert[data-v-e7d93ff5]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.vip-actions[data-v-e7d93ff5]{margin-top:10px}.panel[data-v-e7d93ff5]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.panel-head[data-v-e7d93ff5]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title[data-v-e7d93ff5]{font-size:16px;font-weight:900}.panel-actions[data-v-e7d93ff5]{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.grid[data-v-e7d93ff5]{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;align-items:start}.schedule-card[data-v-e7d93ff5]{border-radius:14px;border:1px solid var(--app-border)}.schedule-top[data-v-e7d93ff5]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.schedule-main[data-v-e7d93ff5]{min-width:0;flex:1}.schedule-title[data-v-e7d93ff5]{display:flex;align-items:center;justify-content:space-between;gap:10px}.schedule-name[data-v-e7d93ff5]{font-size:14px;font-weight:900;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.schedule-meta[data-v-e7d93ff5]{margin-top:6px;display:flex;gap:10px;flex-wrap:wrap;font-size:12px}.schedule-actions[data-v-e7d93ff5]{margin-top:12px;display:flex;gap:8px;flex-wrap:wrap}.logs[data-v-e7d93ff5]{display:flex;flex-direction:column;gap:10px}.log-card[data-v-e7d93ff5]{border-radius:12px;border:1px solid var(--app-border)}.log-head[data-v-e7d93ff5]{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px}.log-body[data-v-e7d93ff5]{margin-top:8px;font-size:13px;line-height:1.6}.log-error[data-v-e7d93ff5]{margin-top:6px;color:#b91c1c}.vip-body[data-v-e7d93ff5]{padding:12px 0 0}.vip-tip[data-v-e7d93ff5]{margin-top:10px;font-size:13px;line-height:1.6}@media(max-width:480px){.grid[data-v-e7d93ff5]{grid-template-columns:1fr}}@media(max-width:768px){.panel-actions[data-v-e7d93ff5]{width:100%;justify-content:flex-end}.schedule-switch[data-v-e7d93ff5]{width:100%;display:flex;justify-content:flex-end}}
|
||||
1
static/app/assets/SchedulesPage-TUv7nqYq.js
Normal file
1
static/app/assets/SchedulesPage-TUv7nqYq.js
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/ScreenshotsPage-30dzddw-.css
Normal file
1
static/app/assets/ScreenshotsPage-30dzddw-.css
Normal file
@@ -0,0 +1 @@
|
||||
.panel[data-v-07cdff63]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.panel-head[data-v-07cdff63]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title[data-v-07cdff63]{font-size:16px;font-weight:900}.panel-actions[data-v-07cdff63]{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.grid[data-v-07cdff63]{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;align-items:start}.pagination[data-v-07cdff63]{margin-top:12px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.page-hint[data-v-07cdff63]{font-size:12px}.shot-card[data-v-07cdff63]{border-radius:14px;border:1px solid var(--app-border);overflow:hidden}.shot-img[data-v-07cdff63]{width:100%;aspect-ratio:16/9;object-fit:cover;cursor:pointer;display:block}.shot-body[data-v-07cdff63]{padding:12px}.shot-name[data-v-07cdff63]{font-size:13px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.shot-meta[data-v-07cdff63]{margin-top:4px;font-size:12px}.shot-actions[data-v-07cdff63]{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px}.preview[data-v-07cdff63]{display:flex;justify-content:center}.preview-img[data-v-07cdff63]{max-width:100%;max-height:78vh;object-fit:contain;border-radius:10px;border:1px solid var(--app-border);background:#fff}@media(max-width:480px){.grid[data-v-07cdff63]{grid-template-columns:1fr}}@media(max-width:768px){.panel-actions[data-v-07cdff63]{width:100%;justify-content:flex-end}}
|
||||
1
static/app/assets/ScreenshotsPage-7CRd3Hlo.js
Normal file
1
static/app/assets/ScreenshotsPage-7CRd3Hlo.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.panel[data-v-76fa8f53]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.panel-head[data-v-76fa8f53]{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title[data-v-76fa8f53]{font-size:16px;font-weight:900}.panel-actions[data-v-76fa8f53]{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.grid[data-v-76fa8f53]{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;align-items:start}.shot-card[data-v-76fa8f53]{border-radius:14px;border:1px solid var(--app-border);overflow:hidden}.shot-img[data-v-76fa8f53]{width:100%;aspect-ratio:16/9;object-fit:cover;cursor:pointer;display:block}.shot-body[data-v-76fa8f53]{padding:12px}.shot-name[data-v-76fa8f53]{font-size:13px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.shot-meta[data-v-76fa8f53]{margin-top:4px;font-size:12px}.shot-actions[data-v-76fa8f53]{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px}.preview[data-v-76fa8f53]{display:flex;justify-content:center}.preview-img[data-v-76fa8f53]{max-width:100%;max-height:78vh;object-fit:contain;border-radius:10px;border:1px solid var(--app-border);background:#fff}@media(max-width:480px){.grid[data-v-76fa8f53]{grid-template-columns:1fr}}@media(max-width:768px){.panel-actions[data-v-76fa8f53]{width:100%;justify-content:flex-end}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.auth-wrap[data-v-1fc6b081]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-1fc6b081]{width:100%;max-width:520px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-1fc6b081]{margin-bottom:14px}.brand-title[data-v-1fc6b081]{font-size:18px;font-weight:900}.brand-sub[data-v-1fc6b081]{margin-top:4px;font-size:12px}.result[data-v-1fc6b081]{padding:8px 0 2px}.actions[data-v-1fc6b081]{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap}.countdown[data-v-1fc6b081]{margin-top:10px;text-align:center;font-size:13px}
|
||||
@@ -1 +0,0 @@
|
||||
import{r as o,c as h,o as R,R as U,n as k,K as i,D as s,aj as d,az as E,q as _,t as l,F as B,C as j,H as C,I as v}from"./vendor-vue-WEaOxmRs.js";import{_ as z}from"./index-mJEiaIbQ.js";import"./vendor-element-D7IaNnTz.js";import"./vendor-misc-0uE2ETD1.js";import"./vendor-axios-B9ygI19o.js";const D={class:"auth-wrap"},W={class:"actions"},$={key:0,class:"countdown app-muted"},q={__name:"VerifyResultPage",setup(F){const T=E(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function x(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=h(()=>!!(r.value&&u.value)),b=h(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await T.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return R(()=>{const e=x();N(e),P()}),U(()=>{a&&window.clearInterval(a)}),(e,t)=>{const I=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",D,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",W,[i(I,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[C(v(w.value),1)]),_:1}),A.value?(_(),j(I,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[C(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",$,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},O=z(q,[["__scopeId","data-v-1fc6b081"]]);export{O as default};
|
||||
1
static/app/assets/VerifyResultPage-bifpPyoE.js
Normal file
1
static/app/assets/VerifyResultPage-bifpPyoE.js
Normal file
@@ -0,0 +1 @@
|
||||
import{b as W,i as N,c as q,w as F,a as G,_ as H,u as J,d as K,E as O}from"./el-button-DWxIvzz-.js";import{E as Q}from"./el-card-DfVpO1U5.js";import{A as R,h as T,j as f,o as a,k as i,l as p,B as w,c as V,C as l,n as d,D as X,t as m,g as n,i as Y,z as Z,q as C,s as b,u as ee,x as P}from"./vendor-vue-DxN60LNb.js";import{_ as se}from"./style-CEbARg1o.js";const r={primary:"icon-primary",success:"icon-success",warning:"icon-warning",error:"icon-error",info:"icon-info"},A={[r.primary]:N,[r.success]:G,[r.warning]:F,[r.error]:q,[r.info]:N},te=W({title:{type:String,default:""},subTitle:{type:String,default:""},icon:{type:String,values:["primary","success","warning","info","error"],default:"info"}}),ne=R({name:"ElResult"}),oe=R({...ne,props:te,setup($){const g=$,o=J("result"),c=T(()=>{const s=g.icon,u=s&&r[s]?r[s]:"icon-info",y=A[u]||A["icon-info"];return{class:u,component:y}});return(s,u)=>(a(),f("div",{class:d(l(o).b())},[i("div",{class:d(l(o).e("icon"))},[w(s.$slots,"icon",{},()=>[l(c).component?(a(),V(X(l(c).component),{key:0,class:d(l(c).class)},null,8,["class"])):p("v-if",!0)])],2),s.title||s.$slots.title?(a(),f("div",{key:0,class:d(l(o).e("title"))},[w(s.$slots,"title",{},()=>[i("p",null,m(s.title),1)])],2)):p("v-if",!0),s.subTitle||s.$slots["sub-title"]?(a(),f("div",{key:1,class:d(l(o).e("subtitle"))},[w(s.$slots,"sub-title",{},()=>[i("p",null,m(s.subTitle),1)])],2)):p("v-if",!0),s.$slots.extra?(a(),f("div",{key:2,class:d(l(o).e("extra"))},[w(s.$slots,"extra")],2)):p("v-if",!0)],2))}});var le=H(oe,[["__file","result.vue"]]);const ae=K(le),re={class:"auth-wrap"},ie={class:"actions"},ce={key:0,class:"countdown app-muted"},ue={__name:"VerifyResultPage",setup($){const g=ee(),o=n(!1),c=n(""),s=n(""),u=n(""),y=n(""),h=n(""),I=n(""),k=n(""),_=n(0);let v=null;function L(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function U(e){const t=!!e?.success;o.value=t,c.value=e?.title||(t?"验证成功":"验证失败"),s.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),u.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),h.value=e?.secondary_label||(t?"":"返回登录"),I.value=e?.secondary_url||(t?"":"/login"),k.value=e?.redirect_url||(t?"/login":""),_.value=Number(e?.redirect_seconds||(t?5:0))||0}const z=T(()=>!!(h.value&&I.value)),B=T(()=>!!(k.value&&_.value>0));async function E(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await g.push(e)}}function D(){B.value&&(v=window.setInterval(()=>{_.value-=1,_.value<=0&&(window.clearInterval(v),v=null,window.location.href=k.value)},1e3))}return Y(()=>{const e=L();U(e),D()}),Z(()=>{v&&window.clearInterval(v)}),(e,t)=>{const S=O,M=ae,j=Q;return a(),f("div",re,[C(j,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:b(()=>[t[2]||(t[2]=i("div",{class:"brand"},[i("div",{class:"brand-title"},"知识管理平台"),i("div",{class:"brand-sub app-muted"},"验证结果")],-1)),C(M,{icon:o.value?"success":"error",title:c.value,"sub-title":s.value,class:"result"},{extra:b(()=>[i("div",ie,[C(S,{type:"primary",onClick:t[0]||(t[0]=x=>E(y.value))},{default:b(()=>[P(m(u.value),1)]),_:1}),z.value?(a(),V(S,{key:0,onClick:t[1]||(t[1]=x=>E(I.value))},{default:b(()=>[P(m(h.value),1)]),_:1})):p("",!0)]),B.value?(a(),f("div",ce,m(_.value)+" 秒后自动跳转... ",1)):p("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},ve=se(ue,[["__scopeId","data-v-1fc6b081"]]);export{ve as default};
|
||||
1
static/app/assets/VerifyResultPage-efSXaaKI.css
Normal file
1
static/app/assets/VerifyResultPage-efSXaaKI.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-result{--el-result-padding:40px 30px;--el-result-icon-font-size:64px;--el-result-title-font-size:20px;--el-result-title-margin-top:20px;--el-result-subtitle-margin-top:10px;--el-result-extra-margin-top:30px;align-items:center;box-sizing:border-box;display:flex;flex-direction:column;justify-content:center;padding:var(--el-result-padding);text-align:center}.el-result__icon svg{height:var(--el-result-icon-font-size);width:var(--el-result-icon-font-size)}.el-result__title{margin-top:var(--el-result-title-margin-top)}.el-result__title p{color:var(--el-text-color-primary);font-size:var(--el-result-title-font-size);line-height:1.3;margin:0}.el-result__subtitle{margin-top:var(--el-result-subtitle-margin-top)}.el-result__subtitle p{color:var(--el-text-color-regular);font-size:var(--el-font-size-base);line-height:1.3;margin:0}.el-result__extra{margin-top:var(--el-result-extra-margin-top)}.el-result .icon-primary{--el-result-color:var(--el-color-primary);color:var(--el-result-color)}.el-result .icon-success{--el-result-color:var(--el-color-success);color:var(--el-result-color)}.el-result .icon-warning{--el-result-color:var(--el-color-warning);color:var(--el-result-color)}.el-result .icon-danger{--el-result-color:var(--el-color-danger);color:var(--el-result-color)}.el-result .icon-error{--el-result-color:var(--el-color-error);color:var(--el-result-color)}.el-result .icon-info{--el-result-color:var(--el-color-info);color:var(--el-result-color)}.auth-wrap[data-v-1fc6b081]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}.auth-card[data-v-1fc6b081]{width:100%;max-width:520px;border-radius:var(--app-radius);border:1px solid var(--app-border);box-shadow:var(--app-shadow)}.brand[data-v-1fc6b081]{margin-bottom:14px}.brand-title[data-v-1fc6b081]{font-size:18px;font-weight:900}.brand-sub[data-v-1fc6b081]{margin-top:4px;font-size:12px}.result[data-v-1fc6b081]{padding:8px 0 2px}.actions[data-v-1fc6b081]{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap}.countdown[data-v-1fc6b081]{margin-top:10px;text-align:center;font-size:13px}
|
||||
1
static/app/assets/accounts-3bM7Wy59.js
Normal file
1
static/app/assets/accounts-3bM7Wy59.js
Normal file
@@ -0,0 +1 @@
|
||||
import{p as c}from"./http-CdvgQxJu.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
||||
@@ -1 +0,0 @@
|
||||
import{p as c}from"./index-mJEiaIbQ.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
||||
1
static/app/assets/accounts-D_6SYB2i.css
Normal file
1
static/app/assets/accounts-D_6SYB2i.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-checkbox-group{font-size:0;line-height:0}
|
||||
2
static/app/assets/app-CZnjzsIN.js
Normal file
2
static/app/assets/app-CZnjzsIN.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./LoginPage-D5iXLq7p.js","./vendor-vue-DxN60LNb.js","./style-CEbARg1o.js","./style-BHGuKLUF.css","./LoginPage-DTj5KeC4.css","./RegisterPage-4xFnBJCQ.js","./el-button-DWxIvzz-.js","./el-button-DF1Fi_iE.css","./el-card-DfVpO1U5.js","./el-card-BqOrgVp1.css","./el-alert-DB2IQLpH.js","./http-CdvgQxJu.js","./vendor-axios-B9ygI19o.js","./http-D6B3r8CH.css","./el-alert-B-NgiIln.css","./auth-CX9p6ZYg.js","./password-7ryi82gE.js","./RegisterPage-BOcNcW5D.css","./ResetPasswordPage-lX7l6Nbu.js","./ResetPasswordPage-DybfLMAw.css","./VerifyResultPage-bifpPyoE.js","./VerifyResultPage-efSXaaKI.css","./AppLayout-Dx0be4wS.js","./user-Bl59IefW.js","./el-overlay-C_JJBVfE.js","./el-overlay-Bd56Lw6C.css","./user-B7bO5p8k.css","./settings-Ddo8isuv.js","./isArrayLikeObject-BjIRF-cS.js","./AppLayout-D94213-a.css","./AccountsPage-DnOxRP7e.js","./accounts-3bM7Wy59.js","./accounts-D_6SYB2i.css","./el-select-B0VMg2td.js","./el-select-D_oyzAZN.css","./vendor-realtime-CA1CrNgP.js","./AccountsPage-iiBFNme8.css","./SchedulesPage-TUv7nqYq.js","./el-pagination-BY1uI-wO.js","./el-pagination-B1FwbX1n.css","./SchedulesPage-BIuHs5oJ.css","./ScreenshotsPage-7CRd3Hlo.js","./ScreenshotsPage-30dzddw-.css"])))=>i.map(i=>d[i]);
|
||||
import{_ as v}from"./style-CEbARg1o.js";import{r as g,c as R,o as y,a as A,b as L,d as w,e as k}from"./vendor-vue-DxN60LNb.js";const V={};function O(p,l){const a=g("RouterView");return y(),R(a)}const T=v(V,[["render",O]]),b="modulepreload",D=function(p,l){return new URL(p,l).href},f={},r=function(l,a,u){let _=Promise.resolve();if(a&&a.length>0){let P=function(e){return Promise.all(e.map(s=>Promise.resolve(s).then(c=>({status:"fulfilled",value:c}),c=>({status:"rejected",reason:c}))))};const n=document.getElementsByTagName("link"),t=document.querySelector("meta[property=csp-nonce]"),h=t?.nonce||t?.getAttribute("nonce");_=P(a.map(e=>{if(e=D(e,u),e in f)return;f[e]=!0;const s=e.endsWith(".css"),c=s?'[rel="stylesheet"]':"";if(u)for(let i=n.length-1;i>=0;i--){const m=n[i];if(m.href===e&&(!s||m.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${e}"]${c}`))return;const o=document.createElement("link");if(o.rel=s?"stylesheet":b,s||(o.as="script"),o.crossOrigin="",o.href=e,h&&o.setAttribute("nonce",h),document.head.appendChild(o),s)return new Promise((i,m)=>{o.addEventListener("load",i),o.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${e}`)))})}))}function d(n){const t=new Event("vite:preloadError",{cancelable:!0});if(t.payload=n,window.dispatchEvent(t),!t.defaultPrevented)throw n}return _.then(n=>{for(const t of n||[])t.status==="rejected"&&d(t.reason);return l().catch(d)})},I=()=>r(()=>import("./LoginPage-D5iXLq7p.js"),__vite__mapDeps([0,1,2,3,4]),import.meta.url),S=()=>r(()=>import("./RegisterPage-4xFnBJCQ.js"),__vite__mapDeps([5,6,1,7,8,9,10,11,12,13,14,15,16,2,3,17]),import.meta.url),$=()=>r(()=>import("./ResetPasswordPage-lX7l6Nbu.js"),__vite__mapDeps([18,6,1,7,8,9,10,11,12,13,14,15,16,2,3,19]),import.meta.url),E=()=>r(()=>import("./VerifyResultPage-bifpPyoE.js"),__vite__mapDeps([20,6,1,7,8,9,2,3,21]),import.meta.url),C=()=>r(()=>import("./AppLayout-Dx0be4wS.js"),__vite__mapDeps([22,6,1,7,23,11,12,13,10,14,24,25,26,27,16,2,3,28,29]),import.meta.url),B=()=>r(()=>import("./AccountsPage-DnOxRP7e.js"),__vite__mapDeps([30,6,1,7,24,11,12,13,25,10,14,23,26,31,32,33,34,8,9,27,35,2,3,36]),import.meta.url),N=()=>r(()=>import("./SchedulesPage-TUv7nqYq.js"),__vite__mapDeps([37,6,1,7,24,11,12,13,25,10,14,33,34,23,26,31,32,38,39,8,9,2,3,28,40]),import.meta.url),j=()=>r(()=>import("./ScreenshotsPage-7CRd3Hlo.js"),__vite__mapDeps([41,6,1,7,24,11,12,13,25,38,33,34,39,8,9,2,3,42]),import.meta.url),q=[{path:"/",redirect:"/login"},{path:"/login",name:"login",component:I},{path:"/register",name:"register",component:S},{path:"/reset-password/:token",name:"reset_password",component:$},{path:"/api/verify-email/:token",name:"verify_email",component:E},{path:"/api/verify-bind-email/:token",name:"verify_bind_email",component:E},{path:"/app",component:C,children:[{path:"",redirect:"/app/accounts"},{path:"accounts",name:"accounts",component:B},{path:"schedules",name:"schedules",component:N},{path:"screenshots",name:"screenshots",component:j}]},{path:"/:pathMatch(.*)*",redirect:"/login"}],x=A({history:L(),routes:q});w(T).use(k()).use(x).mount("#app");
|
||||
@@ -1 +0,0 @@
|
||||
import{p as s}from"./index-mJEiaIbQ.js";async function r(){const{data:a}=await s.get("/email/verify-status");return a}async function o(){const{data:a}=await s.post("/generate_captcha",{});return a}async function e(a){const{data:t}=await s.post("/login",a);return t}async function i(a){const{data:t}=await s.post("/register",a);return t}async function c(a){const{data:t}=await s.post("/resend-verify-email",a);return t}async function f(a){const{data:t}=await s.post("/forgot-password",a);return t}async function u(a){const{data:t}=await s.post("/reset-password-confirm",a);return t}export{f as a,i as b,u as c,r as f,o as g,e as l,c as r};
|
||||
1
static/app/assets/auth-CX9p6ZYg.js
Normal file
1
static/app/assets/auth-CX9p6ZYg.js
Normal file
@@ -0,0 +1 @@
|
||||
import{p as a}from"./http-CdvgQxJu.js";async function e(){const{data:t}=await a.get("/email/verify-status");return t}async function n(){const{data:t}=await a.post("/generate_captcha",{});return t}async function c(t){const{data:s}=await a.post("/register",t);return s}async function i(t){const{data:s}=await a.post("/reset-password-confirm",t);return s}export{i as c,e as f,n as g,c as r};
|
||||
1
static/app/assets/el-alert-B-NgiIln.css
Normal file
1
static/app/assets/el-alert-B-NgiIln.css
Normal file
File diff suppressed because one or more lines are too long
12
static/app/assets/el-alert-DB2IQLpH.js
Normal file
12
static/app/assets/el-alert-DB2IQLpH.js
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/el-button-DF1Fi_iE.css
Normal file
1
static/app/assets/el-button-DF1Fi_iE.css
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/el-button-DWxIvzz-.js
Normal file
1
static/app/assets/el-button-DWxIvzz-.js
Normal file
File diff suppressed because one or more lines are too long
1
static/app/assets/el-card-BqOrgVp1.css
Normal file
1
static/app/assets/el-card-BqOrgVp1.css
Normal file
@@ -0,0 +1 @@
|
||||
.el-card{--el-card-border-color:var(--el-border-color-light);--el-card-border-radius:4px;--el-card-padding:20px;--el-card-bg-color:var(--el-fill-color-blank);background-color:var(--el-card-bg-color);border:1px solid var(--el-card-border-color);border-radius:var(--el-card-border-radius);color:var(--el-text-color-primary);display:flex;flex-direction:column;overflow:hidden;transition:var(--el-transition-duration)}.el-card.is-always-shadow{box-shadow:var(--el-box-shadow-light)}.el-card.is-hover-shadow:focus,.el-card.is-hover-shadow:hover{box-shadow:var(--el-box-shadow-light)}.el-card__header{border-bottom:1px solid var(--el-card-border-color);box-sizing:border-box;padding:calc(var(--el-card-padding) - 2px) var(--el-card-padding)}.el-card__body{flex:1;overflow:auto;padding:var(--el-card-padding)}.el-card__footer{border-top:1px solid var(--el-card-border-color);box-sizing:border-box;padding:calc(var(--el-card-padding) - 2px) var(--el-card-padding)}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user