feat(web): 记住密码/自动登录与版本更新放开到网页形态

- 登录页与首页移除 isDesktopRuntime 门控,网页浏览器同样显示记住密码/自动登录与更新版本入口
- 记住密码由 base64 明文改为 XOR+位移可逆加密存储(v1. 前缀),兼容解密历史值
- 网页形态进入登录页即回填凭据并尝试自动登录(登出/切号导航仍豁免)
- 版本更新在无桌面桥时降级为 OSS 直链下载安装包,桌面端维持自动安装
This commit is contained in:
2026-09-09 00:13:13 +08:00
parent 45fdd8a294
commit ffd8c718dd
3 changed files with 60 additions and 46 deletions
@@ -4,14 +4,12 @@
<span class="header-title">数富AI</span>
<div class="header-right">
<button
v-if="isDesktopRuntime"
type="button"
class="header-update-btn"
title="检测更新"
@click="toggleUpdatePanel"
></button>
<button
v-if="isDesktopRuntime"
type="button"
class="header-update-text"
@click="toggleUpdatePanel"
@@ -67,10 +65,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
import { getCurrentUserAppColumnRaw, type PermissionMenuItem } from '@/shared/api/permission'
import { isDesktopRuntime } from '@/shared/bridges/pywebview'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
import { resolvePageHref } from '@/shared/page-prefix'
@@ -176,16 +173,8 @@ function toggleUpdatePanel() {
onMounted(() => {
void loadCurrentUser()
void loadPermissions()
// 版本检测/更新仅桌面客户端:桌面桥注入后立即检测一次(逻辑见 useVersionUpdate
watch(
isDesktopRuntime,
(runtime) => {
if (runtime) {
void runCheck()
}
},
{ immediate: true },
)
// 版本检测/更新:桌面与网页形态统一挂载后检测一次(逻辑见 useVersionUpdate
void runCheck()
})
</script>
@@ -43,8 +43,8 @@
</button>
</div>
</div>
<!-- 记住密码 / 自动登录桌面客户端提供网页浏览器访问不存密码见需求 -->
<div v-if="isDesktopRuntime" class="login-options">
<!-- 记住密码 / 自动登录桌面与网页均提供网页形态密码经可逆加密后落 localStorage -->
<div class="login-options">
<label class="check-label">
<input type="checkbox" class="check-input" v-model="rememberPassword" />
<span>记住密码</span>
@@ -59,8 +59,8 @@
</button>
</form>
<!-- 版本更新桌面端检测线上版本并下载更新包对齐旧版客户端入口 -->
<div v-if="isDesktopRuntime" class="login-update">
<!-- 版本更新桌面端自动下载安装网页形态降级为下载最新安装包 -->
<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>
@@ -97,7 +97,6 @@
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { loginWithDevice } from '@/shared/api/user'
import { isDesktopRuntime } from '@/shared/bridges/pywebview'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
const router = useRouter()
@@ -132,18 +131,43 @@ const {
doUpdate,
} = useVersionUpdate()
function b64Encode(value: string): string {
function b64Decode(value: string): string {
if (!value) return ''
try {
return btoa(unescape(encodeURIComponent(value)))
return decodeURIComponent(escape(atob(value)))
} catch {
return ''
}
}
function b64Decode(value: string): string {
if (!value) 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 decodeURIComponent(escape(atob(value)))
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 ''
}
@@ -241,14 +265,14 @@ function saveCredentials(account: string) {
return
}
lsSet(REMEMBER_USER_KEY, account)
lsSet(REMEMBER_PWD_KEY, b64Encode(password.value))
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 = b64Decode(lsGet(REMEMBER_PWD_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'
@@ -314,10 +338,8 @@ async function submitLogin() {
/* 忽略 */
}
}
// 登录成功即持久化记住/自动登录凭据(桌面端专属勾选
if (isDesktopRuntime.value) {
saveCredentials(account)
}
// 登录成功即持久化记住/自动登录凭据(桌面与网页均可,密码加密落 localStorage
saveCredentials(account)
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorageJWT 走 Bearer),无 cookie 依赖
clearAppPermissionCaches()
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router
@@ -345,16 +367,9 @@ onMounted(() => {
lsSet(AUTO_LOGIN_KEY, '0')
}
// 桌面桥注入完成后再恢复凭据/自动登录(isDesktopRuntime 在 pywebviewready 后置 true
watch(
isDesktopRuntime,
(runtime) => {
if (!runtime) return
loadCredentials()
tryAutoLogin()
},
{ immediate: true },
)
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免
loadCredentials()
tryAutoLogin()
})
// 勾选自动登录时隐含记住密码(自动登录依赖已存密码);反之取消记住密码则取消自动登录
@@ -109,17 +109,27 @@ export function useVersionUpdate() {
async function doUpdate() {
if (updating.value || !fileUrl.value) return
if (!window.confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return
const bridge = getPywebviewApi()
const doUpdateApp = bridge?.do_update_app
const desktopUpdate = Boolean(doUpdateApp)
const tip = desktopUpdate
? '有更新,是否现在更新?\n更新将下载安装包并重启程序。'
: '发现新版本,是否下载最新安装包?'
if (!window.confirm(tip)) return
updating.value = true
if (!desktopUpdate) {
// 网页/无桌面桥形态:OSS 公开直链直接下载安装包(区别于桌面端自动下载安装)
hint.value = '开始下载安装包,若浏览器未响应请再次点击...'
window.location.href = fileUrl.value
updating.value = false
return
}
hint.value = '正在下载并准备更新,程序将自动退出...'
try {
const bridge = getPywebviewApi()
const result = bridge?.do_update_app
? await bridge.do_update_app(fileUrl.value)
: undefined
const result = await doUpdateApp!(fileUrl.value)
hint.value = result?.success
? '更新已启动,程序即将退出...'
: (result?.error || '当前环境不支持自动更新')
: (result?.error || '更新启动失败,请重试')
} catch {
hint.value = '请求更新失败,请重试'
} finally {