oa-auth-layout-wecom-login-ux 优化认证页登录布局

这个提交包含在:
2026-05-26 21:23:05 +08:00
父节点 15e4bcb0ed
当前提交 afa03c0b71
共修改 13 个文件,包含 392 行新增和 91 行删除
+31
查看文件
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { AppBrand } from "../shell/AppBrand";
import { cn } from "../../lib/utils";
type AuthPanelProps = {
title: string;
subtitle?: string;
children: ReactNode;
footer?: ReactNode;
className?: string;
"data-testid"?: string;
};
export function AuthPanel({ title, subtitle, children, footer, className, "data-testid": testId }: AuthPanelProps) {
return (
<section
className={cn("w-full rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8", className)}
data-testid={testId}
>
<div className="space-y-7">
<AppBrand compact />
<div>
<h1 className="text-xl font-semibold leading-7 text-slate-950">{title}</h1>
{subtitle ? <p className="mt-1 text-sm leading-6 text-slate-500">{subtitle}</p> : null}
</div>
<div>{children}</div>
{footer ? <div className="border-t border-slate-100 pt-5 text-center text-sm">{footer}</div> : null}
</div>
</section>
);
}
+2 -6
查看文件
@@ -1,5 +1,4 @@
import { Outlet } from "react-router-dom";
import { AppBrand } from "../components/shell/AppBrand";
import { Button } from "../components/ui/button";
import { usePwaUpdatePrompt } from "../hooks/usePwaUpdatePrompt";
@@ -7,11 +6,8 @@ export function AuthLayout() {
const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt();
return (
<div className="min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-10">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-md flex-col justify-center">
<div className="mb-5">
<AppBrand />
</div>
<div className="min-h-screen bg-[var(--color-bg-canvas)] px-4 py-8 sm:px-6 lg:px-10">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
{updateReady ? (
<div className="mb-4 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm text-slate-700" data-testid="auth-pwa-update-notice">
<div className="font-medium text-slate-900">系统有新版本</div>
+6 -22
查看文件
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { AuthPanel } from "../components/auth/AuthPanel";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { PasswordInput } from "../components/ui/password-input";
@@ -50,17 +51,9 @@ export function ChangePasswordPage() {
}
return (
<div className="card-std space-y-6 border-primary-100 p-6">
<div className="mb-6 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
<h1 className="text-2xl font-bold text-zinc-900">修改密码</h1>
<p className="text-sm text-secondary">
为了账号安全,请先修改初始密码后再进入系统。
</p>
</div>
<AuthPanel title="修改密码" subtitle="为了账号安全,请先修改初始密码">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="oldPassword">旧密码</Label>
<PasswordInput
autoComplete="current-password"
@@ -72,7 +65,7 @@ export function ChangePasswordPage() {
/>
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="newPassword">新密码</Label>
<PasswordInput
autoComplete="new-password"
@@ -85,16 +78,7 @@ export function ChangePasswordPage() {
<p className="text-xs text-zinc-500">密码要求:{PASSWORD_REQUIREMENTS.join(";")}。</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
<p className="font-medium text-zinc-700">建议的新密码规则</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{PASSWORD_REQUIREMENTS.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="confirmPassword">确认新密码</Label>
<PasswordInput
autoComplete="new-password"
@@ -123,6 +107,6 @@ export function ChangePasswordPage() {
{submitting ? "提交中..." : "确认修改"}
</Button>
</form>
</div>
</AuthPanel>
);
}
+13 -14
查看文件
@@ -3,6 +3,7 @@ import { Link, useNavigate } from "react-router-dom";
import { forgotPassword, resetPassword } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { AuthPanel } from "../components/auth/AuthPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -93,13 +94,17 @@ export function ForgotPasswordPage() {
}
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6" data-testid="forgot-password-page">
<div>
<h1 className="text-xl font-semibold text-slate-900">找回密码</h1>
<p className="mt-1 text-sm leading-6 text-slate-500">使用账号邮箱接收验证码,设置新密码后重新登录。</p>
</div>
<form className="mt-6 space-y-4" onSubmit={handleRequestCode}>
<AuthPanel
title="找回密码"
subtitle="使用账号邮箱接收验证码"
data-testid="forgot-password-page"
footer={(
<Link className="font-medium text-primary hover:underline" to="/login">
返回登录
</Link>
)}
>
<form className="space-y-4" onSubmit={handleRequestCode}>
<div className="space-y-2">
<Label htmlFor="forgot-password-email">邮箱</Label>
<Input
@@ -181,12 +186,6 @@ export function ForgotPasswordPage() {
{error}
</div>
) : null}
<div className="mt-5 text-center text-sm">
<Link className="font-medium text-primary hover:underline" to="/login">
返回登录
</Link>
</div>
</div>
</AuthPanel>
);
}
+35 -31
查看文件
@@ -5,6 +5,7 @@ import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { AuthPanel } from "../components/auth/AuthPanel";
import { getDeviceFingerprint } from "../auth/deviceFingerprint";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -108,7 +109,7 @@ export function LoginPage() {
function renderPasswordPanel(testId: string) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm" data-testid={testId}>
<div data-testid={testId}>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="login-identifier">账号</Label>
@@ -150,29 +151,22 @@ export function LoginPage() {
}
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<div>
<h1 className="text-xl font-semibold text-slate-900">登录工作台</h1>
<p className="mt-1 text-sm text-slate-500">
{showWecomPrimary ? "企业微信进入优先。" : "账号密码登录。"}
</p>
</div>
<div className="mt-6 space-y-4">
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : "账号密码登录"}>
<div className="space-y-5">
{wecomNotice ? (
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className="font-medium">{wecomNotice.title}</div>
<div className="mt-1 leading-6">{wecomNotice.description}</div>
</div>
) : null}
{showWecomPrimary ? (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4" data-testid="login-wecom-primary-panel">
<div className="space-y-3" data-testid="login-wecom-primary-panel">
<Button className="w-full" data-testid="wecom-entry-primary" disabled={!wecomEntry} type="button" onClick={handleWecomLogin}>
{wecomConfigQuery.isLoading ? "准备中..." : "进入工作台"}
{wecomConfigQuery.isLoading ? "准备中..." : "企业微信一键进入"}
</Button>
{wecomConfig && !wecomEntry ? (
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
企业微信入口不可用,请用账号密码。
</div>
) : null}
@@ -192,25 +186,35 @@ export function LoginPage() {
? renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
: null}
{!showWecomPrimary && wecomEntry ? (
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
企业微信登录
</Button>
) : null}
{!showWecomPrimary && (wecomEntry || oidcProvider) ? (
<div className="space-y-3">
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className="h-px flex-1 bg-slate-100" />
<span>其他登录方式</span>
<span className="h-px flex-1 bg-slate-100" />
</div>
{!showWecomPrimary && oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
{wecomEntry ? (
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
企业微信登录
</Button>
) : null}
{oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
) : null}
</div>
) : null}
</div>
</div>
</AuthPanel>
);
}
-18
查看文件
@@ -1,17 +1,5 @@
import { expect, test } from "@playwright/test";
import { EMPLOYEE, login } from "./helpers/auth";
import { openProfileSettings } from "./helpers/navigation";
function runtimeEmployee() {
const identifier = process.env.LIVE_SMOKE_EMPLOYEE_IDENTIFIER?.trim();
const password = process.env.LIVE_SMOKE_EMPLOYEE_PASSWORD?.trim();
if (identifier && password) {
return { identifier, password, name: "Live Smoke Employee" };
}
return EMPLOYEE;
}
function mockWecomConfigPayload() {
return {
enabled: true,
@@ -52,12 +40,6 @@ test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async (
await page.getByTestId("wecom-entry-secondary").click();
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/);
const employee = runtimeEmployee();
await login(page, employee);
await openProfileSettings(page, employee.name);
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
await expect(page.getByText("接入状态", { exact: true })).toBeVisible();
});
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => {