feat: R27-2 R27-5 优化企微入口与 Portal 体验
这个提交包含在:
+148
-76
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
|
||||
@@ -8,20 +8,33 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import {
|
||||
buildWecomLoginUrl,
|
||||
resolveWecomBindNotice,
|
||||
resolveWecomEntryStatus,
|
||||
resolveWecomLoginNotice,
|
||||
type WecomNotice,
|
||||
} from "../lib/wecomEntry";
|
||||
|
||||
function resolveRedirect(searchParams: URLSearchParams) {
|
||||
const redirect = searchParams.get("redirect");
|
||||
if (!redirect) {
|
||||
if (!redirect || !redirect.startsWith("/") || redirect.startsWith("//")) {
|
||||
return "/portal";
|
||||
}
|
||||
|
||||
if (!redirect.startsWith("/")) {
|
||||
return "/portal";
|
||||
}
|
||||
|
||||
return redirect;
|
||||
}
|
||||
|
||||
function noticeClasses(tone: WecomNotice["tone"]) {
|
||||
if (tone === "success") {
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
if (tone === "warning") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-700";
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -33,18 +46,29 @@ export function LoginPage() {
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const redirectTo = resolveRedirect(searchParams);
|
||||
const isWecomContext = isWecomWebView();
|
||||
|
||||
const ssoProvidersQuery = useQuery({
|
||||
queryKey: ["auth", "sso-providers"],
|
||||
queryFn: () => fetchSsoProviders(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
|
||||
|
||||
const wecomConfigQuery = useQuery({
|
||||
queryKey: ["auth", "wecom-config"],
|
||||
queryFn: () => fetchWecomConfig(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const wecomEntry = wecomConfigQuery.data?.enabled ? wecomConfigQuery.data : null;
|
||||
|
||||
const wecomConfig = wecomConfigQuery.data;
|
||||
const wecomStatus = resolveWecomEntryStatus(wecomConfig);
|
||||
const wecomEntry = wecomConfig?.entryEnabled ? wecomConfig : null;
|
||||
const primaryMode = isWecomContext && wecomEntry?.loginUrl ? "wecom" : "password";
|
||||
const wecomNotice = useMemo(
|
||||
() => resolveWecomBindNotice(searchParams.get("wecomBind")) ?? resolveWecomLoginNotice(searchParams.get("wecom")),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -71,85 +95,133 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleWecomLogin() {
|
||||
if (!wecomEntry?.loginUrl) {
|
||||
return;
|
||||
}
|
||||
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-6 border-primary-100 p-6">
|
||||
<div className="mb-6">
|
||||
<div className="card-std overflow-hidden border-primary-100 p-0">
|
||||
<div className="border-b border-zinc-200 bg-gradient-to-br from-primary-50 via-white to-zinc-50 p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900">登录工作台</h1>
|
||||
<p className="mt-2 text-sm text-secondary">使用邮箱/手机号与密码登录。</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">忘记密码:请联系管理员重置。</p>
|
||||
<p className="mt-2 text-sm text-secondary">审批、项目、周报和团队状态统一在 OA 完成。</p>
|
||||
<div className="mt-4 rounded-xl border border-zinc-200 bg-white/90 px-4 py-3 text-sm text-zinc-600">
|
||||
{isWecomContext
|
||||
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台。"
|
||||
: "桌面浏览器可直接使用账号密码登录;企业微信仅作为入口与提醒。"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="identifier">账号</Label>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
data-testid="login-identifier"
|
||||
id="identifier"
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="邮箱或手机号"
|
||||
value={identifier}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
data-testid="login-password"
|
||||
id="password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="login-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<div className="space-y-5 p-6">
|
||||
{wecomNotice ? (
|
||||
<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">{wecomNotice.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{oidcProvider || wecomEntry ? (
|
||||
<div className="space-y-3 border-t border-zinc-200 pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500">企业统一入口</p>
|
||||
{wecomEntry?.loginUrl ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const loginUrl = wecomEntry.loginUrl ?? "/api/auth/wecom/login";
|
||||
window.location.assign(loginUrl.replace(/returnTo=%2Fportal$/, `returnTo=${encodeURIComponent(redirectTo)}`));
|
||||
}}
|
||||
>
|
||||
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
|
||||
<div className="space-y-3 rounded-2xl border border-primary-100 bg-primary-50/70 p-5" data-testid="login-wecom-primary-panel">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">推荐入口</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-zinc-900">使用企业微信进入工作台</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600">在企业微信工作台打开时,优先使用免登入口;失败时仍可回退到账号密码登录。</p>
|
||||
</div>
|
||||
<Button className="w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
|
||||
使用企业微信进入工作台
|
||||
</Button>
|
||||
) : null}
|
||||
{oidcProvider ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
<p className="text-xs text-zinc-500">若当前企业微信账号尚未绑定,请先使用账号密码登录,再到个人设置完成绑定。</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-zinc-200 p-5" data-testid={primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel"}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">账号密码登录</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{primaryMode === "password"
|
||||
? "使用公司邮箱或手机号登录,复杂填写与审批仍建议在 Web OA 内完成。"
|
||||
: "企业微信不可用时,可改用账号密码登录。"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">忘记密码:请联系管理员重置。</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="identifier">账号</Label>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
data-testid="login-identifier"
|
||||
id="identifier"
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="邮箱或手机号"
|
||||
value={identifier}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
data-testid="login-password"
|
||||
id="password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="login-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primaryMode === "password" ? "default" : "secondary"}>
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
|
||||
<div className="space-y-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">其他入口</p>
|
||||
<p className="mt-1 text-sm text-zinc-600">企业微信负责打开 OA 和接收提醒,实际业务仍在 OA 内完成。</p>
|
||||
</div>
|
||||
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
|
||||
<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}
|
||||
|
||||
{isWecomContext && !wecomEntry?.loginUrl && !wecomConfigQuery.isLoading ? (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomStatus.tone)}`}>
|
||||
<div className="font-medium">企业微信入口当前不可用</div>
|
||||
<div className="mt-1">{wecomStatus.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户