8803e22f39
- 新增公开接口 GET /api/version/list(最近 50 条,字段口径同管理端列表), 桌面端下拉不再拼 OSS 地址(版本包被删后拼地址会 404) - 更新面板(登录页 + 首页)加「指定版本更新」选择器:默认选中最新版, 可选全部已发布版本(含回退),回退/同版给出明确文案与二次确认 - 客户端无需发版:do_update_app(file_url) 本就接受任意版本包直链
818 lines
22 KiB
Vue
818 lines
22 KiB
Vue
<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 同步已随瘦身下线:登录态只存 localStorage(JWT 走 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>
|