From f8cef26f4acbfcc70bd41a8243e519ca91afa005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Tue, 15 Sep 2026 11:50:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=99=BB=E5=BD=95/=E6=94=B9?= =?UTF-8?q?=E5=AF=86/=E6=88=90=E5=91=98=E8=A1=A8=E5=8D=95=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E5=8F=AF=E8=A7=81=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/MembersSection.tsx | 3 ++- web/src/PasswordInput.test.tsx | 24 ++++++++++++++++++++++++ web/src/PasswordInput.tsx | 29 +++++++++++++++++++++++++++++ web/src/auth.tsx | 9 +++++---- web/src/styles.css | 20 ++++++++++++++++++++ 5 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 web/src/PasswordInput.test.tsx create mode 100644 web/src/PasswordInput.tsx diff --git a/web/src/MembersSection.tsx b/web/src/MembersSection.tsx index 2a68cc7..ba32ec2 100644 --- a/web/src/MembersSection.tsx +++ b/web/src/MembersSection.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { createManagedUser, fetchManagedUsers, resetManagedUserPassword, setManagedUserRole, setManagedUserState } from './members' import type { ManagedUser } from './members' import { roleLabels, useOptionalAuth } from './auth' +import { PasswordInput } from './PasswordInput' export const memberRoleLabels = roleLabels @@ -73,7 +74,7 @@ export function MembersSection() { - + 停用不会删除任何业务记录,只禁止登录并立即撤销其在线会话。 diff --git a/web/src/PasswordInput.test.tsx b/web/src/PasswordInput.test.tsx new file mode 100644 index 0000000..2eaff9f --- /dev/null +++ b/web/src/PasswordInput.test.tsx @@ -0,0 +1,24 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { PasswordInput } from './PasswordInput' + +describe('PasswordInput', () => { + it('masks the field by default and offers a visibility toggle', () => { + const markup = renderToStaticMarkup( undefined} />) + expect(markup).toContain('type="password"') + expect(markup).toContain('显示密码') + expect(markup).toContain('aria-pressed="false"') + }) + + it('renders revealed when initiallyRevealed is set', () => { + const markup = renderToStaticMarkup( undefined} />) + expect(markup).toContain('type="text"') + expect(markup).toContain('隐藏密码') + expect(markup).toContain('aria-pressed="true"') + }) + + it('toggle is a plain button so it never submits the form', () => { + const markup = renderToStaticMarkup( undefined} />) + expect(markup).toContain('type="button"') + }) +}) diff --git a/web/src/PasswordInput.tsx b/web/src/PasswordInput.tsx new file mode 100644 index 0000000..85166de --- /dev/null +++ b/web/src/PasswordInput.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react' +import type { InputHTMLAttributes } from 'react' + +// PasswordInput renders a password field with an inline visibility toggle so +// users can verify what they typed before submitting. The toggle is a +// presentation-only button (type=button) nested in a relative wrapper; it +// never submits the surrounding form. +interface PasswordInputProps extends Omit, 'type'> { + autoComplete: string + initiallyRevealed?: boolean +} + +export function PasswordInput({ autoComplete, initiallyRevealed = false, ...inputProps }: PasswordInputProps) { + const [revealed, setRevealed] = useState(initiallyRevealed) + return ( + + + + + ) +} diff --git a/web/src/auth.tsx b/web/src/auth.tsx index 5278b57..d52e1f9 100644 --- a/web/src/auth.tsx +++ b/web/src/auth.tsx @@ -1,5 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' +import { PasswordInput } from './PasswordInput' export type AuthRole = 'viewer' | 'engineer' | 'admin' @@ -153,7 +154,7 @@ export function LoginScreen() {

知识治理平台

登录后所有操作将记录到你名下

- + {error ?

{error}

: null} @@ -185,9 +186,9 @@ export function MustChangePasswordScreen() {

首次登录需要设置新密码

账号 {identity?.username} 的初始密码只能完成这一次登录

- - - + + + {error ?

{error}

: null} diff --git a/web/src/styles.css b/web/src/styles.css index 714f9f5..0abe816 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1948,6 +1948,26 @@ tbody tr:hover { } .login-card input:focus { outline: 2px solid var(--c-run); outline-offset: 1px; border-color: transparent; } .login-card button[type="submit"] { width: 100%; margin-top: 4px; } + +/* Password visibility toggle: button sits inside the field's right edge. */ +.password-field { position: relative; display: block; } +.password-field input { width: 100%; padding-right: 56px; } +.password-toggle { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + padding: 2px 8px; + border: 1px solid var(--line-base, #d7dbe0); + border-radius: 6px; + background: #fff; + color: var(--ink-base, #333); + font-size: 12px; + font-weight: 600; + line-height: 20px; + cursor: pointer; +} +.password-toggle:hover { border-color: var(--c-run, #2f6feb); color: var(--c-run, #2f6feb); } .login-error { margin: 0; font-size: 13px; color: var(--c-fail); background: var(--c-fail-bg); border: 1px solid var(--c-fail-border); border-radius: 6px; padding: 8px 10px; } .login-secondary { background: transparent; border: none; color: var(--ink-mute); font-size: 12.5px; cursor: pointer; text-decoration: underline; }