feat: improve login UX and add admin login lock reset

- Translate all login error messages to Chinese (network errors, HTTP
  status codes, URL validation errors)
- Add password visibility toggle (eye icon) to login form
- Add live countdown timer when login is locked, disable login button
  during cooldown, auto-clear error when countdown ends
- Add X button red hover feedback in server settings dialog
- Add LoginGuard.Unlock() method to clear Redis lock/failure keys
- Expose POST /admin-users/:id/reset-login-lock endpoint in ops API
- Add "重置登录保护" button in ops-web user management table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Xu Liang
2026-05-02 19:25:15 +08:00
parent 40c877ee2d
commit 6dd3bd8e9d
9 changed files with 212 additions and 23 deletions
@@ -391,10 +391,23 @@ function formatLoginError(err: unknown): string {
login_in_progress: '登录请求正在处理中',
login_guard_unavailable: '登录保护暂时不可用',
}
const message = messages[err.message] ?? err.message.replaceAll('_', ' ')
return err.detail ? `${message}${err.detail}` : message
if (err.message in messages) {
const message = messages[err.message]!
return err.message === 'login_locked' && err.detail ? `${message}${err.detail}` : message
}
if (err.status === 404) return '登录接口不存在,请确认服务器地址'
if (err.status && err.status >= 500) return '服务器内部错误,请稍后重试'
if (err.status && err.status >= 400) return '请求被服务器拒绝,请检查账号信息'
if (!err.status) return '网络连接失败,请检查服务器地址或网络'
return err.message.replaceAll('_', ' ')
}
return err instanceof Error ? err.message : '登录失败,请稍后重试'
if (err instanceof Error) {
const msg = err.message
if (msg.includes('Invalid URL') || msg.includes('Failed to construct')) {
return '服务器地址无效,请检查网关 API 地址设置'
}
}
return '登录失败,请稍后重试'
}
async function performLogin(payload: LoginRequest): Promise<void> {
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { DEFAULT_API_BASE_URL, useDesktopSession } from '../composables/useDesktopSession'
const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession()
const menuOpen = ref(false)
const showServerDialog = ref(false)
const showPassword = ref(false)
const rememberPassword = ref(!!localStorage.getItem('geo_rankly_saved_password'))
const savedIdentifier =
localStorage.getItem('geo_rankly_saved_identifier') ??
@@ -69,12 +70,45 @@ async function submitLogin() {
await window.desktopBridge?.app?.setWindowMode?.('main', { source: 'login' })
}
const lockCountdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
function clearCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
watch(error, (msg) => {
clearCountdown()
lockCountdown.value = 0
if (!msg) return
const match = msg.match(/请\s*(\d+)\s*秒后重试/)
if (!match) return
lockCountdown.value = parseInt(match[1], 10)
countdownTimer = setInterval(() => {
lockCountdown.value--
if (lockCountdown.value <= 0) clearCountdown()
}, 1000)
}, { flush: 'sync' })
const displayError = computed(() => {
if (!error.value) return null
if (error.value.startsWith('登录已被临时保护')) {
if (lockCountdown.value <= 0) return null
return error.value.replace(/\d+(?=\s*秒后重试)/, String(lockCountdown.value))
}
return error.value
})
onMounted(() => {
document.addEventListener('mousedown', handleOutsideClick)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleOutsideClick)
clearCountdown()
})
</script>
@@ -122,14 +156,25 @@ onBeforeUnmount(() => {
/>
</div>
<div class="field">
<div class="field field--password">
<input
v-model="form.password"
type="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
placeholder="密码"
required
/>
<button type="button" class="eye-toggle" tabindex="-1" @click="showPassword = !showPassword">
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
<div class="options-row">
@@ -139,11 +184,11 @@ onBeforeUnmount(() => {
</label>
</div>
<button type="submit" class="submit-button" :disabled="pending">
<button type="submit" class="submit-button" :disabled="pending || lockCountdown > 0">
{{ pending ? '登录中...' : '登 录' }}
</button>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-if="displayError" class="error-text">{{ displayError }}</p>
</form>
</div>
@@ -171,7 +216,6 @@ onBeforeUnmount(() => {
spellcheck="false"
/>
</label>
<p class="dialog-hint">修改后对本机下次登录生效</p>
</div>
<footer class="dialog-footer">
<button type="button" class="btn-ghost" @click="closeServerSettings">取消</button>
@@ -424,6 +468,40 @@ onBeforeUnmount(() => {
0 0 0 3px rgba(22, 119, 255, 0.12);
}
.field--password {
position: relative;
}
.field--password input {
padding-right: 42px;
}
.eye-toggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: #9aa4b8;
cursor: pointer;
}
.eye-toggle:hover {
color: #1677ff;
}
.eye-toggle svg {
width: 16px;
height: 16px;
}
.options-row {
display: flex;
justify-content: flex-end;
@@ -538,8 +616,13 @@ onBeforeUnmount(() => {
}
.dialog-close:hover {
background: #f2f6ff;
color: #1677ff;
background: #fff0f0;
color: #e53935;
}
.dialog-close:active {
background: #ffe0e0;
color: #c62828;
}
.dialog-body {
@@ -330,7 +330,6 @@ watch(apiBaseURL, (next) => {
overflow: hidden;
background: #f4f4f5;
color: #0a0a0a;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
}
.settings-nav {
@@ -418,8 +417,8 @@ watch(apiBaseURL, (next) => {
.settings-heading h1 {
margin: 0;
color: #000000;
font-size: 22px;
font-weight: 800;
font-size:22px;
font-weight: 900;
line-height: 1;
}