Files
crawler-plugin/frontend-vue/src/pages/login/DesktopLoginPage.vue
T
huangzd1997 8803e22f39 feat(version): 桌面端更新面板支持指定版本安装
- 新增公开接口 GET /api/version/list(最近 50 条,字段口径同管理端列表),
  桌面端下拉不再拼 OSS 地址(版本包被删后拼地址会 404)
- 更新面板(登录页 + 首页)加「指定版本更新」选择器:默认选中最新版,
  可选全部已发布版本(含回退),回退/同版给出明确文案与二次确认
- 客户端无需发版:do_update_app(file_url) 本就接受任意版本包直链
2026-09-14 11:05:30 +08:00

818 lines
22 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>
<!-- 版本更新入口固定右上角桌面端自动下载安装网页形态降级为下载最新安装包 -->
<div class="login-header-right">
<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>
<UpdateLogList :entries="changelog" />
<UpdateProgressBar :progress="progress" />
<VersionPicker
:versions="versions"
:current-version="currentVersion"
:loading="versionListLoading"
:error="versionListError"
:updating="updating"
@reload="loadVersions(true)"
@update="doUpdateVersion"
/>
</div>
</div>
</div>
</header>
<div class="login-box">
<h1 class="login-title">登录</h1>
<p v-if="kickedNotice" class="kicked-msg">该账号已在其他设备登录本设备已下线如非本人操作请及时修改密码</p>
<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>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { loginWithDevice } from '@/shared/api/user'
import { KICK_NOTICE_KEY } from '@/shared/auth/kick-handler'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
import { clearApiSecretCache } from '@/shared/utils/api-secret-store'
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
import UpdateLogList from '@/shared/components/UpdateLogList.vue'
import VersionPicker from '@/shared/components/VersionPicker.vue'
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 kickedNotice = ref(false)
const rememberPassword = ref(false)
const autoLogin = ref(false)
const updateOpen = ref(false)
// 版本检测 / 更新:登录页与首页共用逻辑(指定版本更新见 VersionPicker
const {
checking,
updating,
currentVersion,
latestVersion,
hasUpdate,
canDownload,
hint,
checked,
progress,
changelog,
versions,
versionListLoading,
versionListError,
loadVersions,
runCheck,
doUpdate,
doUpdateVersion,
} = useVersionUpdate()
function b64Decode(value: string): string {
if (!value) return ''
try {
return decodeURIComponent(escape(atob(value)))
} catch {
return ''
}
}
// 记住密码存储:AES-GCM 对称加密,密文前缀 v2.。
// 说明:纯前端 localStorage 无法做强密码保护——加密密钥与密文同机存放,
// 防的是"源码公开即可解密/明文直读",而非本机攻击者。
const PWD_ENC_PREFIX = 'v2.'
const PWD_KEY_STORAGE = 'aiimage_pwd_enc_key'
let pwdCryptoKeyPromise: Promise<CryptoKey | null> | null = null
function getPwdCryptoKey(): Promise<CryptoKey | null> {
if (!pwdCryptoKeyPromise) {
pwdCryptoKeyPromise = (async () => {
try {
if (!window.crypto?.subtle) return null
let rawB64 = lsGet(PWD_KEY_STORAGE)
if (!rawB64) {
const raw = new Uint8Array(32)
window.crypto.getRandomValues(raw)
rawB64 = btoa(String.fromCharCode(...raw))
lsSet(PWD_KEY_STORAGE, rawB64)
}
const raw = Uint8Array.from(atob(rawB64), (ch) => ch.charCodeAt(0))
return await window.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
} catch {
return null
}
})()
}
return pwdCryptoKeyPromise
}
async function encryptRememberPwd(plain: string): Promise<string> {
try {
const key = await getPwdCryptoKey()
if (!key) return ''
const iv = new Uint8Array(12)
window.crypto.getRandomValues(iv)
const cipher = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plain),
)
const cipherBytes = new Uint8Array(cipher)
const merged = new Uint8Array(iv.length + cipherBytes.length)
merged.set(iv, 0)
merged.set(cipherBytes, iv.length)
return PWD_ENC_PREFIX + btoa(String.fromCharCode(...merged))
} catch {
return ''
}
}
async function decryptRememberPwd(stored: string): Promise<string> {
if (!stored) return ''
if (!stored.startsWith(PWD_ENC_PREFIX)) {
// 兼容历史值:v1. 为 XOR 可逆编码、其余为纯 base64;解出后由调用方重存为 v2.
if (stored.startsWith('v1.')) return ''
return b64Decode(stored)
}
try {
const key = await getPwdCryptoKey()
if (!key) return ''
const merged = Uint8Array.from(atob(stored.slice(PWD_ENC_PREFIX.length)), (ch) => ch.charCodeAt(0))
const iv = merged.slice(0, 12)
const cipher = merged.slice(12)
const plain = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
return new TextDecoder().decode(plain)
} 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'
}
/** 被新设备顶下线后跳回登录页(query 会被路由守卫吞掉,故另看 localStorage 标记) */
function isKickedNav(): boolean {
try {
if (new URLSearchParams(window.location.search || '').get('kicked') === '1') {
return true
}
return lsGet(KICK_NOTICE_KEY) === '1'
} catch {
return false
}
}
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()
}
/** 登录成功后按勾选状态持久化账号凭据(仅桌面端登录页可勾选) */
async 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, await encryptRememberPwd(password.value))
lsSet(AUTO_LOGIN_KEY, autoLogin.value ? '1' : '0')
}
/** 打开登录页时恢复记住的账号/勾选状态 */
async function loadCredentials() {
const account = lsGet(REMEMBER_USER_KEY)
const pwd = await 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()
}
// 指定版本更新的可选版本:首次展开面板时拉一次(模块级缓存,切换页面不重复请求)
if (updateOpen.value) {
void loadVersions()
}
}
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 {
/* 忽略 */
}
// 同步登录用户到桌面客户端配置:Python 端按 uid 选用该用户自己的
// 代理密钥/代理池(各自计费、各自复用省钱)。网页形态无 pywebview
// 桥,静默跳过。
try {
const bridge = (window as unknown as { pywebview?: { api?: { save_config?: (data: Record<string, unknown>) => Promise<unknown> } } }).pywebview
if (bridge?.api && typeof bridge.api.save_config === 'function') {
void bridge.api.save_config({ current_uid: String(data.userId) })
}
} catch {
/* 忽略 */
}
}
if (data.username) {
try {
window.localStorage.setItem('username', String(data.username))
} catch {
/* 忽略 */
}
}
// 登录成功即持久化记住/自动登录凭据(桌面与网页均可,密码 AES 加密落 localStorage
void 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 {
/* 忽略 */
}
// 清空密钥缓存:内存 + 本地镜像(旧 v1 记录保留,供下次登录自动迁移)
clearApiSecretCache()
// 同步清掉客户端的登录用户标记:Python 端回退到全局/默认代理池
try {
const bridge = (window as unknown as { pywebview?: { api?: { save_config?: (data: Record<string, unknown>) => Promise<unknown> } } }).pywebview
if (bridge?.api && typeof bridge.api.save_config === 'function') {
void bridge.api.save_config({ current_uid: '' })
}
} catch {
/* 忽略 */
}
// 显式登出/切号后不再自动登录(保留记住的账号,仅关闭自动登录)
autoLogin.value = false
lsSet(AUTO_LOGIN_KEY, '0')
}
if (isKickedNav()) {
// 被新设备顶下线:提示原因并关闭自动登录——否则本机每次启动都会静默重登,
// 把对方又顶下线,两台机器来回互踢。标记展示后即清(避免登出时误显示)。
kickedNotice.value = true
autoLogin.value = false
lsSet(AUTO_LOGIN_KEY, '0')
lsRemove(KICK_NOTICE_KEY)
}
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免)
// loadCredentials 现为异步(AES 解密),须先恢复密码再触发自动登录
void (async () => {
await loadCredentials()
if (kickedNotice.value) {
// 刚被顶下线:不自动重登,等用户手动点登录(此时顶掉对方,最后登录者胜)
autoLogin.value = false
return
}
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;
}
/* 单设备登录:被新设备顶下线的提示 */
.kicked-msg {
color: #b8860b;
background: #fdf6e3;
border: 1px solid #f0d9a0;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
margin-bottom: 12px;
padding: 8px 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- 前缀,避免与首页同名全局样式互相覆盖) */
.login-header-right {
display: flex;
align-items: center;
}
.login-update {
position: relative;
}
.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 {
position: absolute;
top: 34px;
right: 0;
z-index: 20;
width: 300px;
padding: 12px 14px;
background: #ffffff;
border: 1px solid #d5e6f2;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(20, 50, 80, 0.16);
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>