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

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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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 {

View 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
View 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>

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -9,14 +9,20 @@ import { fetchMyFeedbacks, submitFeedback } from '../api/feedback'
import {
bindEmail,
changePassword,
createUserPasskeyOptions,
createUserPasskeyVerify,
deleteUserPasskey,
fetchEmailNotify,
fetchUserPasskeys,
fetchUserEmail,
fetchKdocsSettings,
reportUserPasskeyClientError,
unbindEmail,
updateKdocsSettings,
updateEmailNotify,
} from '../api/settings'
import { useUserStore } from '../stores/user'
import { createPasskey, isPasskeyAvailable } from '../utils/passkey'
import { validateStrongPassword } from '../utils/password'
const route = useRoute()
@@ -116,6 +122,13 @@ const passwordForm = reactive({
const kdocsLoading = ref(false)
const kdocsSaving = ref(false)
const kdocsUnitValue = ref('')
const passkeyLoading = ref(false)
const passkeyAddLoading = ref(false)
const passkeyDeviceName = ref('')
const passkeyItems = ref([])
const passkeyRegisterOptions = ref(null)
const passkeyRegisterOptionsAt = ref(0)
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
function syncIsMobile() {
isMobile.value = Boolean(mediaQuery?.matches)
@@ -237,7 +250,7 @@ async function openSettings() {
}
async function loadSettings() {
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings()])
await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings(), loadPasskeys()])
}
async function loadEmailInfo() {
@@ -292,6 +305,116 @@ async function saveKdocsSettings() {
}
}
async function loadPasskeys() {
passkeyLoading.value = true
try {
const data = await fetchUserPasskeys()
passkeyItems.value = Array.isArray(data?.items) ? data.items : []
if (passkeyItems.value.length < 3) {
await prefetchPasskeyRegisterOptions()
} else {
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
}
} catch {
passkeyItems.value = []
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
} finally {
passkeyLoading.value = false
}
}
function getCachedPasskeyRegisterOptions() {
if (!passkeyRegisterOptions.value) return null
if (Date.now() - Number(passkeyRegisterOptionsAt.value || 0) > PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS) return null
return passkeyRegisterOptions.value
}
async function prefetchPasskeyRegisterOptions() {
try {
const res = await createUserPasskeyOptions({})
passkeyRegisterOptions.value = res
passkeyRegisterOptionsAt.value = Date.now()
} catch {
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
}
}
async function onAddPasskey() {
if (!isPasskeyAvailable()) {
ElMessage.error('当前浏览器或环境不支持Passkey需 HTTPS')
return
}
if (passkeyItems.value.length >= 3) {
ElMessage.error('最多可绑定3台设备')
return
}
passkeyAddLoading.value = true
try {
let optionsRes = getCachedPasskeyRegisterOptions()
if (!optionsRes) {
optionsRes = await createUserPasskeyOptions({})
}
const credential = await createPasskey(optionsRes?.publicKey || {})
await createUserPasskeyVerify({ credential, device_name: passkeyDeviceName.value.trim() })
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
passkeyDeviceName.value = ''
ElMessage.success('Passkey设备添加成功')
await loadPasskeys()
} catch (e) {
try {
await reportUserPasskeyClientError({
stage: 'register',
source: 'user-settings',
name: e?.name || '',
message: e?.message || '',
code: e?.code || '',
user_agent: navigator.userAgent || '',
})
} catch {
// ignore report failure
}
passkeyRegisterOptions.value = null
passkeyRegisterOptionsAt.value = 0
await prefetchPasskeyRegisterOptions()
const data = e?.response?.data
const clientMessage = e?.message ? String(e.message) : ''
const message =
data?.error ||
(e?.name === 'NotAllowedError'
? `Passkey注册未完成浏览器返回${clientMessage || '未提供详细原因'}`
: clientMessage || 'Passkey添加失败')
ElMessage.error(message)
} finally {
passkeyAddLoading.value = false
}
}
async function onDeletePasskey(item) {
try {
await ElMessageBox.confirm(`确定删除设备「${item?.device_name || '未命名设备'}」吗?`, '删除Passkey设备', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await deleteUserPasskey(item.id)
ElMessage.success('设备已删除')
await loadPasskeys()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
async function onBindEmail() {
const email = bindEmailValue.value.trim().toLowerCase()
if (!email) {
@@ -665,6 +788,47 @@ async function dismissAnnouncementPermanently() {
</div>
</el-tab-pane>
<el-tab-pane label="Passkey设备" name="passkeys">
<div class="settings-section" v-loading="passkeyLoading">
<el-alert
type="info"
:closable="false"
title="最多可绑定3台设备用于无密码登录。"
show-icon
class="settings-alert"
/>
<el-form inline>
<el-form-item label="设备备注">
<el-input
v-model="passkeyDeviceName"
placeholder="例如我的iPhone / 办公Mac"
maxlength="40"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="passkeyAddLoading" @click="onAddPasskey">
添加Passkey设备
</el-button>
</el-form-item>
</el-form>
<el-empty v-if="passkeyItems.length === 0" description="暂无Passkey设备" />
<el-table v-else :data="passkeyItems" size="small" style="width: 100%">
<el-table-column prop="device_name" label="设备备注" min-width="160" />
<el-table-column prop="credential_id_preview" label="凭据ID" min-width="180" />
<el-table-column prop="last_used_at" label="最近使用" min-width="140" />
<el-table-column prop="created_at" label="创建时间" min-width="140" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" text @click="onDeletePasskey(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="表格上传" name="kdocs">
<div v-loading="kdocsLoading" class="settings-section">
<el-form label-position="top">

View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import LoginPage from './pages/LoginPage.vue'
import './style.css'
createApp(LoginPage).mount('#app')

View File

@@ -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')

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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')

View 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)
}

View File

@@ -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
View File

@@ -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):

View File

@@ -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 ====================

View File

@@ -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:

View File

@@ -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
View 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

View File

@@ -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)")

View File

@@ -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

View File

@@ -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():

View File

@@ -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"):

View File

@@ -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():

View File

@@ -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)

View File

@@ -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

View File

@@ -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():

View File

@@ -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
View 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 ""

View File

@@ -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

View File

@@ -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

View File

@@ -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};

View File

@@ -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)}

View 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

View 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}}

View File

@@ -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}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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};

View File

@@ -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};

View File

@@ -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};

View File

@@ -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">

View File

@@ -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"
]
}
}

View File

@@ -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

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

View File

@@ -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}}

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

View 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};

View File

@@ -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};

View File

@@ -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};

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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}}

File diff suppressed because one or more lines are too long

View 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}}

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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}

View File

@@ -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};

View 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};

View 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}

View 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};

View File

@@ -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};

View File

@@ -0,0 +1 @@
.el-checkbox-group{font-size:0;line-height:0}

View 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");

View File

@@ -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};

View 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};

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

View 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