Files
crawler-plugin/frontend-vue/src/pages/login/DesktopLoginPage.vue
T

676 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="legacy-login-root">
<header class="header">
<span class="header-title">数富AI</span>
</header>
<div class="login-box">
<h1 class="login-title">登录</h1>
<p v-if="errorMessage" class="error-msg">{{ errorMessage }}</p>
<form id="loginForm" @submit.prevent="submitLogin">
<div class="form-group">
<label for="username">用户名</label>
<input id="username" v-model="username" type="text" placeholder="请输入用户名" required autofocus />
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="password-field">
<input
id="password"
v-model="password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="请输入密码"
required
/>
<button
type="button"
class="password-toggle"
:class="{ 'is-visible': passwordVisible }"
:aria-pressed="passwordVisible"
:aria-label="passwordVisible ? '隐藏密码' : '显示密码'"
@click="togglePassword"
>
<svg class="icon-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg class="icon-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m2 2 20 20"></path>
<path d="M10.733 5.076a10.744 10.744 0 0 1 9.205 6.272 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-1.651 2.59"></path>
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"></path>
<path d="M17.479 17.499a10.75 10.75 0 0 1-5.479 1.501c-4.478 0-8.268-2.943-9.876-6.652a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 2.646-3.393"></path>
</svg>
</button>
</div>
</div>
<!-- 记住密码 / 自动登录桌面与网页均提供网页形态密码经可逆加密后落 localStorage -->
<div class="login-options">
<label class="check-label">
<input type="checkbox" class="check-input" v-model="rememberPassword" />
<span>记住密码</span>
</label>
<label class="check-label">
<input type="checkbox" class="check-input" v-model="autoLogin" />
<span>自动登录</span>
</label>
</div>
<button type="submit" class="btn-login" id="btnLogin" :disabled="loggingIn">
{{ loggingIn ? '登录中...' : '登录' }}
</button>
</form>
<!-- 版本更新桌面端自动下载安装网页形态降级为下载最新安装包 -->
<div class="login-update">
<button type="button" class="link-update" @click="toggleUpdate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36"></path>
<path d="M21 3v6h-6"></path>
</svg>
更新版本
</button>
<div v-if="updateOpen" class="update-panel">
<div class="update-panel-title">软件更新</div>
<div class="update-row"><span>当前版本</span><b>{{ currentVersion || '—' }}</b></div>
<div class="update-row"><span>最新版本</span><b>{{ latestVersion || '—' }}</b></div>
<div class="update-actions">
<button type="button" class="btn-mini" :disabled="checking" @click="runCheck">
{{ checking ? '检测中...' : '检测' }}
</button>
<button
v-if="hasUpdate || canDownload"
type="button"
class="btn-mini btn-mini-green"
:disabled="updating"
@click="doUpdate"
>
{{ updating ? '更新中...' : '立即更新' }}
</button>
</div>
<div class="update-hint">{{ hint }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { loginWithDevice } from '@/shared/api/user'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
const router = useRouter()
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
const DEVICE_ID_KEY = 'aiimage_device_id'
const REMEMBER_USER_KEY = 'aiimage_remember_user'
const REMEMBER_PWD_KEY = 'aiimage_remember_pwd'
const AUTO_LOGIN_KEY = 'aiimage_auto_login'
const username = ref('')
const password = ref('')
const passwordVisible = ref(false)
const loggingIn = ref(false)
const errorMessage = ref('')
const rememberPassword = ref(false)
const autoLogin = ref(false)
const updateOpen = ref(false)
// 版本检测 / 更新:登录页与首页共用逻辑
const {
checking,
updating,
currentVersion,
latestVersion,
hasUpdate,
canDownload,
hint,
checked,
runCheck,
doUpdate,
} = useVersionUpdate()
function b64Decode(value: string): string {
if (!value) return ''
try {
return decodeURIComponent(escape(atob(value)))
} catch {
return ''
}
}
// 记住密码存储:可逆加密(XOR+位移再 base64),带版本前缀 v1.。
// 说明:纯前端 localStorage 无法做强密码保护,此仅规避明文与早期 base64 直存。
const PWD_ENC_PREFIX = 'v1.'
const PWD_ENC_KEY = [0x5a, 0x3c, 0x9f, 0x2e, 0x71, 0x8b, 0x1d, 0xe6]
function encryptRememberPwd(plain: string): string {
const bytes = plain.split('').map((ch) => ch.charCodeAt(0))
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = (bytes[i] ^ PWD_ENC_KEY[i % PWD_ENC_KEY.length] ^ i) & 0xff
}
try {
return PWD_ENC_PREFIX + btoa(String.fromCharCode(...bytes))
} catch {
return ''
}
}
function decryptRememberPwd(stored: string): string {
if (!stored) return ''
if (!stored.startsWith(PWD_ENC_PREFIX)) {
// 兼容早期仅 base64 的历史值
return b64Decode(stored)
}
try {
const bytes = atob(stored.slice(PWD_ENC_PREFIX.length)).split('').map((ch) => ch.charCodeAt(0))
return bytes
.map((code, i) => String.fromCharCode((code ^ PWD_ENC_KEY[i % PWD_ENC_KEY.length] ^ i) & 0xff))
.join('')
} catch {
return ''
}
}
function lsGet(key: string): string {
try {
return window.localStorage.getItem(key) || ''
} catch {
return ''
}
}
function lsSet(key: string, value: string) {
try {
window.localStorage.setItem(key, value)
} catch {
/* 忽略 localStorage 异常 */
}
}
function lsRemove(key: string) {
try {
window.localStorage.removeItem(key)
} catch {
/* 忽略 */
}
}
function hasLogoutParam(): boolean {
const params = new URLSearchParams(window.location.search || '')
return params.get('logout') === '1' || params.get('switch') === '1'
}
function togglePassword() {
passwordVisible.value = !passwordVisible.value
}
function clearAppPermissionCaches() {
try {
const keys: string[] = []
for (let i = 0; i < window.localStorage.length; i += 1) {
const key = window.localStorage.key(i)
if (key && key.indexOf('app_column_permissions:') === 0) keys.push(key)
}
keys.forEach((key) => window.localStorage.removeItem(key))
} catch {
/* 忽略 localStorage 异常 */
}
}
function makeBrowserDeviceId() {
let deviceId = ''
try {
deviceId = window.localStorage.getItem(DEVICE_ID_KEY) || ''
} catch {
deviceId = ''
}
if (!deviceId) {
deviceId = `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
try {
window.localStorage.setItem(DEVICE_ID_KEY, deviceId)
} catch {
/* 忽略 */
}
}
return deviceId
}
async function fetchDeviceId(): Promise<string> {
if (window.pywebview?.api?.get_device_id) {
try {
const res = await window.pywebview.api.get_device_id()
if (res?.success && res.device_id) {
try {
window.localStorage.setItem(DEVICE_ID_KEY, res.device_id)
} catch {
/* 忽略 */
}
return res.device_id
}
} catch {
/* 回退浏览器生成 */
}
}
return makeBrowserDeviceId()
}
/** 登录成功后按勾选状态持久化账号凭据(仅桌面端登录页可勾选,密码 base64 简单编码) */
function saveCredentials(account: string) {
if (!rememberPassword.value) {
lsRemove(REMEMBER_USER_KEY)
lsRemove(REMEMBER_PWD_KEY)
lsRemove(AUTO_LOGIN_KEY)
return
}
lsSet(REMEMBER_USER_KEY, account)
lsSet(REMEMBER_PWD_KEY, encryptRememberPwd(password.value))
lsSet(AUTO_LOGIN_KEY, autoLogin.value ? '1' : '0')
}
/** 打开登录页时恢复记住的账号/勾选状态 */
function loadCredentials() {
const account = lsGet(REMEMBER_USER_KEY)
const pwd = decryptRememberPwd(lsGet(REMEMBER_PWD_KEY))
if (account) username.value = account
if (pwd) password.value = pwd
const auto = lsGet(AUTO_LOGIN_KEY) === '1'
autoLogin.value = auto
rememberPassword.value = auto || Boolean(pwd)
}
/** 打开登录页即用记住的凭据静默登录(登出/切号导航除外) */
function tryAutoLogin() {
if (hasLogoutParam()) return
if (!autoLogin.value) return
if (!username.value || !lsGet(REMEMBER_PWD_KEY)) return
void submitLogin()
}
function toggleUpdate() {
updateOpen.value = !updateOpen.value
if (updateOpen.value && !checked.value && !checking.value) {
void runCheck()
}
}
async function submitLogin() {
if (loggingIn.value) return
const account = username.value.trim()
if (!account || !password.value) {
errorMessage.value = '请输入账号和密码'
return
}
loggingIn.value = true
errorMessage.value = ''
try {
const deviceId = await fetchDeviceId()
if (!deviceId) {
errorMessage.value = '未获取到设备ID,请在桌面端打开'
return
}
// 经 shared/api/user 层登录(页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
const loginBody = await loginWithDevice(account, password.value, deviceId)
if (!loginBody || loginBody.success !== true || !loginBody.data) {
errorMessage.value = (loginBody && (loginBody.message || loginBody.msg)) || '登录失败'
return
}
const data = loginBody.data || {}
if (data.token) {
try {
window.localStorage.setItem(AUTH_TOKEN_KEY, data.token)
} catch {
/* 忽略 */
}
}
if (data.userId) {
try {
window.localStorage.setItem('uid', String(data.userId))
} catch {
/* 忽略 */
}
}
if (data.username) {
try {
window.localStorage.setItem('username', String(data.username))
} catch {
/* 忽略 */
}
}
// 登录成功即持久化记住/自动登录凭据(桌面与网页均可,密码加密落 localStorage
saveCredentials(account)
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorageJWT 走 Bearer),无 cookie 依赖
clearAppPermissionCaches()
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router
await router.replace('/home')
} catch {
errorMessage.value = '网络异常,请稍后重试'
} finally {
loggingIn.value = false
}
}
onMounted(() => {
const params = new URLSearchParams(window.location.search || '')
if (params.get('logout') === '1' || params.get('switch') === '1') {
// 登出/切号:uid 不彻底清除会被路由守卫当“已登录”自动带回首页(见 src/main.ts 守卫)
try {
window.localStorage.removeItem(AUTH_TOKEN_KEY)
window.localStorage.removeItem('uid')
window.localStorage.removeItem('username')
} catch {
/* 忽略 */
}
// 显式登出/切号后不再自动登录(保留记住的账号,仅关闭自动登录)
autoLogin.value = false
lsSet(AUTO_LOGIN_KEY, '0')
}
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免)
loadCredentials()
tryAutoLogin()
})
// 勾选自动登录时隐含记住密码(自动登录依赖已存密码);反之取消记住密码则取消自动登录
watch(autoLogin, (checkedValue) => {
if (checkedValue) rememberPassword.value = true
})
watch(rememberPassword, (checkedValue) => {
if (!checkedValue) autoLogin.value = false
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
min-height: 100vh;
}
.legacy-login-root {
min-height: 100vh;
/* 页面背景放根容器,不动全局 body:避免被首页等页面的 body 背景规则覆盖导致残留 */
background: #d8e4ec;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
box-sizing: border-box;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: rgba(255, 255, 255, 0.5);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.login-box {
position: relative;
background: #fff;
border: 1px solid #b8d4e3;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
padding: 40px;
width: 100%;
max-width: 380px;
}
.login-title {
font-size: 24px;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
color: #555;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px 14px;
font-size: 14px;
border: 1px solid #b8d4e3;
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
background: #fff;
}
.form-group input:focus {
border-color: #3498db;
}
.password-field {
position: relative;
}
.password-field input {
padding-right: 48px;
}
.password-toggle {
position: absolute;
top: 50%;
right: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: #71808d;
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
transform: translateY(-50%);
}
.password-toggle:hover {
color: #3498db;
background: #f1f7fb;
}
.password-toggle svg {
width: 18px;
height: 18px;
pointer-events: none;
}
.password-toggle .icon-eye-off {
display: none;
}
.password-toggle.is-visible .icon-eye {
display: none;
}
.password-toggle.is-visible .icon-eye-off {
display: block;
}
.error-msg {
color: #e74c3c;
font-size: 13px;
margin-bottom: 12px;
text-align: center;
}
/* 记住密码 / 自动登录 */
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: -4px 0 16px;
}
.check-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #555;
cursor: pointer;
user-select: none;
}
.check-input {
width: 15px;
height: 15px;
accent-color: #3498db;
cursor: pointer;
}
.btn-login {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 500;
color: #fff;
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.btn-login:hover:not(:disabled) {
opacity: 0.9;
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 版本更新入口与面板 */
.login-update {
margin-top: 14px;
text-align: center;
}
.link-update {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
font-size: 13px;
color: #71808d;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
}
.link-update svg {
width: 14px;
height: 14px;
}
.link-update:hover {
color: #3498db;
background: #f1f7fb;
border-color: #d5e6f2;
}
.update-panel {
margin-top: 10px;
padding: 12px 14px;
background: #f6fafd;
border: 1px solid #d5e6f2;
border-radius: 8px;
text-align: left;
}
.update-panel-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.update-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: #555;
line-height: 1.7;
}
.update-row b {
font-weight: 600;
color: #333;
}
.update-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.btn-mini {
padding: 5px 12px;
font-size: 13px;
color: #fff;
background: #3498db;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn-mini:hover:not(:disabled) {
opacity: 0.9;
}
.btn-mini:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-mini-green {
background: #28a745;
}
.btn-mini-green:hover:not(:disabled) {
opacity: 0.9;
}
.update-hint {
font-size: 12px;
color: #666;
margin-top: 8px;
min-height: 16px;
}
</style>