feat: 登录页改用 SSR 预取机构列表,消除首屏闪烁

**问题**
- 登录页机构下拉在客户端 useEffect 中拉取,首屏会闪现"正在加载机构列表"
- 浏览器端多一跳 fetch 请求,体感延迟明显

**改动**
- page.tsx → 服务端组件(async),SSR 时内网直连 /api/v1/organizations
- login-form.tsx → 客户端组件,接收 initialOrgs,保留表单交互
- useEffect 降级为仅当 SSR 为空时的客户端兜底
- force-dynamic + cache: no-store 保证机构变化即时生效

**验证**
- 本地 next start:首屏 HTML 直接含机构名,无占位符
- 构建路由从 ○ Static 变为 ƒ Dynamic
This commit is contained in:
selfrelease
2026-06-25 20:58:18 +08:00
parent 15beea041a
commit 9d6498bf19
2 changed files with 248 additions and 218 deletions
@@ -0,0 +1,225 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuthStore } from "@/stores/auth";
import type { Organization } from "@/stores/auth";
import api from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Shield,
Building2,
Sparkles,
BookOpen,
FileText,
Brain,
GraduationCap,
} from "lucide-react";
export default function LoginForm({ initialOrgs }: { initialOrgs: Organization[] }) {
const [email, setEmail] = useState("fazhiribao@govai.gov.cn");
const [password, setPassword] = useState("admin123");
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [orgs, setOrgs] = useState<Organization[]>(initialOrgs);
const [selectedOrg, setSelectedOrg] = useState(initialOrgs[0]?.id ?? "");
const { login, switchOrg } = useAuthStore();
const router = useRouter();
// SSR 已注入机构时直接使用,无需再请求;
// 仅当服务端取数失败(首屏机构为空)时,客户端兜底拉取一次
useEffect(() => {
if (initialOrgs.length > 0) return;
api
.get<Organization[]>("/api/v1/organizations")
.then((data) => {
setOrgs(data);
if (data.length > 0) setSelectedOrg(data[0].id);
})
.catch(() => {});
}, [initialOrgs.length]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setErrorMsg("请输入邮箱和密码");
return;
}
if (!selectedOrg) {
setErrorMsg("请选择所属机构");
return;
}
setLoading(true);
setErrorMsg("");
try {
await login(email, password, selectedOrg);
const user = useAuthStore.getState().user;
// 平台管理员不绑定机构,登录后保留 super_admin 身份;
// 仅机构管理员在所选机构与自身归属不一致时才触发切换
if (user && user.role === "admin" && user.org_id !== selectedOrg) {
await switchOrg(selectedOrg);
}
router.push(user?.role === "super_admin" ? "/platform/overview" : "/store");
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "登录失败,请检查账号和密码");
} finally {
setLoading(false);
}
};
const features = [
{ icon: Sparkles, text: "AI 驱动的智能办公" },
{ icon: FileText, text: "一键生成公文与报告" },
{ icon: BookOpen, text: "智能知识库问答" },
{ icon: Brain, text: "多场景 AI 应用中心" },
{ icon: GraduationCap, text: "支持多机构独立部署" },
];
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-950 via-blue-900 to-blue-800 px-6 py-12">
<div className="flex w-full max-w-[1000px] items-center gap-16 lg:gap-20">
{/* 左侧品牌区 - 桌面端显示 */}
<div className="hidden lg:flex lg:flex-1 flex-col">
<div className="max-w-md">
<div className="flex items-center gap-3 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<Shield className="h-8 w-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-white tracking-tight">AI </h1>
<p className="text-blue-200/80 text-sm mt-0.5"> · </p>
</div>
</div>
<p className="text-blue-100/70 text-lg leading-relaxed mb-10">
AI
</p>
<div className="space-y-4">
{features.map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 backdrop-blur-sm">
<Icon className="h-5 w-5 text-blue-200" />
</div>
<span className="text-blue-100/90 text-base">{text}</span>
</div>
))}
</div>
</div>
</div>
{/* 右侧登录区 */}
<div className="flex w-full lg:w-auto lg:shrink-0 items-center justify-center">
<div className="w-full max-w-[460px] rounded-2xl bg-white shadow-2xl border border-white/20 overflow-hidden">
{/* 移动端标题 - 仅在小屏显示 */}
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center">
<Shield className="h-10 w-10 text-white mx-auto mb-3" />
<h1 className="text-xl font-bold text-white">AI </h1>
<p className="text-blue-200/80 text-sm mt-1"> · </p>
</div>
{/* 表单区域 */}
<div className="px-8 sm:px-10 py-8 sm:py-10">
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
</h2>
<p className="hidden lg:block text-sm text-gray-500 mb-8"></p>
<form onSubmit={handleSubmit} className="space-y-5">
{errorMsg && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{errorMsg}
</div>
)}
<div className="space-y-2">
<Label htmlFor="org" className="text-sm font-medium text-gray-700">
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
</span>
</Label>
{orgs.length > 0 ? (
<select
id="org"
value={selectedOrg}
onChange={(e) => setSelectedOrg(e.target.value)}
className="flex h-11 w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
>
{orgs.map((org) => (
<option key={org.id} value={org.id}>
{org.short_name || org.name}
</option>
))}
</select>
) : (
<Input disabled placeholder="正在加载机构列表..." className="h-11 rounded-xl" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
</Label>
<Input
id="email"
type="email"
placeholder="your@gov.cn"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium text-gray-700">
</Label>
<Input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</div>
<Button
type="submit"
className="w-full h-12 text-base font-medium rounded-xl bg-blue-900 hover:bg-blue-800 transition-all duration-200 shadow-lg shadow-blue-900/25 hover:shadow-xl hover:shadow-blue-900/30 mt-2"
disabled={loading}
>
{loading ? "登录中..." : "登 录"}
</Button>
</form>
<div className="mt-6 text-center text-sm text-gray-500">
{" "}
<Link
href="/register"
className="text-blue-600 font-medium hover:text-blue-700 underline-offset-4 hover:underline"
>
</Link>
</div>
<div className="mt-8 text-center text-xs text-gray-400 border-t border-gray-100 pt-5">
使 ·
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+23 -218
View File
@@ -1,222 +1,27 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuthStore } from "@/stores/auth";
import type { Organization } from "@/stores/auth";
import api from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Shield,
Building2,
Sparkles,
BookOpen,
FileText,
Brain,
GraduationCap,
} from "lucide-react";
import LoginForm from "./login-form";
export default function LoginPage() {
const [email, setEmail] = useState("fazhiribao@govai.gov.cn");
const [password, setPassword] = useState("admin123");
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [orgs, setOrgs] = useState<Organization[]>([]);
const [selectedOrg, setSelectedOrg] = useState("");
const { login, switchOrg } = useAuthStore();
const router = useRouter();
// 登录页改为服务端渲染:SSR 阶段在 web 服务端内网直连 API 取机构列表,
// 首屏 HTML 即带机构数据,消除客户端"正在加载机构列表"的闪烁。
// no-store 保证机构增删改即时生效,无需重新构建或重启。
export const dynamic = "force-dynamic";
useEffect(() => {
api
.get<Organization[]>("/api/v1/organizations")
.then((data) => {
setOrgs(data);
if (data.length > 0) setSelectedOrg(data[0].id);
})
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setErrorMsg("请输入邮箱和密码");
return;
}
if (!selectedOrg) {
setErrorMsg("请选择所属机构");
return;
}
setLoading(true);
setErrorMsg("");
try {
await login(email, password, selectedOrg);
const user = useAuthStore.getState().user;
// 平台管理员不绑定机构,登录后保留 super_admin 身份;
// 仅机构管理员在所选机构与自身归属不一致时才触发切换
if (user && user.role === "admin" && user.org_id !== selectedOrg) {
await switchOrg(selectedOrg);
}
router.push(user?.role === "super_admin" ? "/platform/overview" : "/store");
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "登录失败,请检查账号和密码");
} finally {
setLoading(false);
}
};
const features = [
{ icon: Sparkles, text: "AI 驱动的智能办公" },
{ icon: FileText, text: "一键生成公文与报告" },
{ icon: BookOpen, text: "智能知识库问答" },
{ icon: Brain, text: "多场景 AI 应用中心" },
{ icon: GraduationCap, text: "支持多机构独立部署" },
];
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-950 via-blue-900 to-blue-800 px-6 py-12">
<div className="flex w-full max-w-[1000px] items-center gap-16 lg:gap-20">
{/* 左侧品牌区 - 桌面端显示 */}
<div className="hidden lg:flex lg:flex-1 flex-col">
<div className="max-w-md">
<div className="flex items-center gap-3 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<Shield className="h-8 w-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-white tracking-tight">AI </h1>
<p className="text-blue-200/80 text-sm mt-0.5"> · </p>
</div>
</div>
<p className="text-blue-100/70 text-lg leading-relaxed mb-10">
AI
</p>
<div className="space-y-4">
{features.map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 backdrop-blur-sm">
<Icon className="h-5 w-5 text-blue-200" />
</div>
<span className="text-blue-100/90 text-base">{text}</span>
</div>
))}
</div>
</div>
</div>
{/* 右侧登录区 */}
<div className="flex w-full lg:w-auto lg:shrink-0 items-center justify-center">
<div className="w-full max-w-[460px] rounded-2xl bg-white shadow-2xl border border-white/20 overflow-hidden">
{/* 移动端标题 - 仅在小屏显示 */}
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center">
<Shield className="h-10 w-10 text-white mx-auto mb-3" />
<h1 className="text-xl font-bold text-white">AI </h1>
<p className="text-blue-200/80 text-sm mt-1"> · </p>
</div>
{/* 表单区域 */}
<div className="px-8 sm:px-10 py-8 sm:py-10">
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
</h2>
<p className="hidden lg:block text-sm text-gray-500 mb-8"></p>
<form onSubmit={handleSubmit} className="space-y-5">
{errorMsg && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{errorMsg}
</div>
)}
<div className="space-y-2">
<Label htmlFor="org" className="text-sm font-medium text-gray-700">
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
</span>
</Label>
{orgs.length > 0 ? (
<select
id="org"
value={selectedOrg}
onChange={(e) => setSelectedOrg(e.target.value)}
className="flex h-11 w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
>
{orgs.map((org) => (
<option key={org.id} value={org.id}>
{org.short_name || org.name}
</option>
))}
</select>
) : (
<Input disabled placeholder="正在加载机构列表..." className="h-11 rounded-xl" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
</Label>
<Input
id="email"
type="email"
placeholder="your@gov.cn"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium text-gray-700">
</Label>
<Input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</div>
<Button
type="submit"
className="w-full h-12 text-base font-medium rounded-xl bg-blue-900 hover:bg-blue-800 transition-all duration-200 shadow-lg shadow-blue-900/25 hover:shadow-xl hover:shadow-blue-900/30 mt-2"
disabled={loading}
>
{loading ? "登录中..." : "登 录"}
</Button>
</form>
<div className="mt-6 text-center text-sm text-gray-500">
{" "}
<Link
href="/register"
className="text-blue-600 font-medium hover:text-blue-700 underline-offset-4 hover:underline"
>
</Link>
</div>
<div className="mt-8 text-center text-xs text-gray-400 border-t border-gray-100 pt-5">
使 ·
</div>
</div>
</div>
</div>
</div>
</div>
);
async function getOrganizations(): Promise<Organization[]> {
const base = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
try {
const res = await fetch(`${base}/api/v1/organizations`, {
cache: "no-store",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) return [];
const json = (await res.json()) as { code: number; data: Organization[] };
return json.code === 0 && Array.isArray(json.data) ? json.data : [];
} catch {
return [];
}
}
export default async function LoginPage() {
const orgs = await getOrganizations();
return <LoginForm initialOrgs={orgs} />;
}