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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user