|
|
@@ -21,9 +21,11 @@ import {
|
|
|
loginSession,
|
|
|
logoutSession,
|
|
|
restoreSession,
|
|
|
+ setupPortalPasswordSession,
|
|
|
type AuthenticatedPage,
|
|
|
type NavigationItem,
|
|
|
} from "../features/auth/authSession";
|
|
|
+import { portalAuthApi } from "../features/auth/portalAuthApi";
|
|
|
import type { LoginRequest, UserSummary } from "../features/auth/authTypes";
|
|
|
import { CategoryType, EnabledStatus } from "../shared/api/enums";
|
|
|
import { categoryApi } from "../features/categories/categoryApi";
|
|
|
@@ -2339,6 +2341,186 @@ function LoginPage({ onLogin, notice }: {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+function PortalLaunchScreen({ query, onLogin, onSetupLogin, onFallback }: {
|
|
|
+ query: string;
|
|
|
+ onLogin: (request: LoginRequest) => Promise<void>;
|
|
|
+ onSetupLogin: (ticket: string, newPassword: string, keepSignedIn: boolean) => Promise<void>;
|
|
|
+ onFallback: () => void;
|
|
|
+}) {
|
|
|
+ const [stage, setStage] = useState<"verifying" | "login" | "setup" | "error">("verifying");
|
|
|
+ const [username, setUsername] = useState("");
|
|
|
+ const [realName, setRealName] = useState("");
|
|
|
+ const [ticket, setTicket] = useState("");
|
|
|
+ const [password, setPassword] = useState("");
|
|
|
+ const [confirmPassword, setConfirmPassword] = useState("");
|
|
|
+ const [keepSignedIn, setKeepSignedIn] = useState(false);
|
|
|
+ const [error, setError] = useState("");
|
|
|
+ const [errorRequestId, setErrorRequestId] = useState("");
|
|
|
+ const [submitting, setSubmitting] = useState(false);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let cancelled = false;
|
|
|
+ portalAuthApi.verify(query).then((data) => {
|
|
|
+ if (cancelled) return;
|
|
|
+ setUsername(data.username);
|
|
|
+ setRealName(data.realName);
|
|
|
+ if (data.phase === "SET_PASSWORD") {
|
|
|
+ setTicket(data.portalTicket);
|
|
|
+ setStage("setup");
|
|
|
+ } else {
|
|
|
+ setStage("login");
|
|
|
+ }
|
|
|
+ }).catch((verifyError) => {
|
|
|
+ if (cancelled) return;
|
|
|
+ if (verifyError instanceof HttpError) {
|
|
|
+ setError(verifyError.message);
|
|
|
+ setErrorRequestId(verifyError.requestId);
|
|
|
+ } else {
|
|
|
+ setError("门户验签失败,请稍后重试");
|
|
|
+ }
|
|
|
+ setStage("error");
|
|
|
+ });
|
|
|
+ return () => { cancelled = true; };
|
|
|
+ }, [query]);
|
|
|
+
|
|
|
+ const captureFormError = (formError: unknown, fallback: string) => {
|
|
|
+ if (formError instanceof HttpError) {
|
|
|
+ setError(formError.message);
|
|
|
+ setErrorRequestId(formError.requestId);
|
|
|
+ } else {
|
|
|
+ setError(fallback);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const submitLogin = async () => {
|
|
|
+ if (submitting) return;
|
|
|
+ if (!password.trim()) { setError("请输入密码"); return; }
|
|
|
+ setSubmitting(true); setError(""); setErrorRequestId("");
|
|
|
+ try {
|
|
|
+ await onLogin({ username, password, keepSignedIn });
|
|
|
+ } catch (loginError) {
|
|
|
+ captureFormError(loginError, "登录失败,请稍后重试");
|
|
|
+ } finally {
|
|
|
+ setSubmitting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const submitSetup = async () => {
|
|
|
+ if (submitting) return;
|
|
|
+ if (password.length < 8 || password.length > 64) { setError("密码长度必须在8~64个字符之间"); return; }
|
|
|
+ if (password !== confirmPassword) { setError("两次输入的密码不一致"); return; }
|
|
|
+ setSubmitting(true); setError(""); setErrorRequestId("");
|
|
|
+ try {
|
|
|
+ await onSetupLogin(ticket, password, keepSignedIn);
|
|
|
+ } catch (setupError) {
|
|
|
+ captureFormError(setupError, "设置密码失败,请稍后重试");
|
|
|
+ } finally {
|
|
|
+ setSubmitting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (stage === "verifying") {
|
|
|
+ return <AuthStateScreen title="正在验证门户跳转" description="正在向门户验证跳转凭证,请稍候。" primaryLabel="" onPrimary={() => { }} busy />;
|
|
|
+ }
|
|
|
+ if (stage === "error") {
|
|
|
+ return (
|
|
|
+ <AuthStateScreen
|
|
|
+ title="门户跳转失败"
|
|
|
+ description={error}
|
|
|
+ requestId={errorRequestId || undefined}
|
|
|
+ primaryLabel="重试"
|
|
|
+ onPrimary={() => { setError(""); setErrorRequestId(""); setStage("verifying"); }}
|
|
|
+ secondaryLabel="使用账号密码登录"
|
|
|
+ onSecondary={onFallback}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const isSetup = stage === "setup";
|
|
|
+ const handlePrimary = isSetup ? () => void submitSetup() : () => void submitLogin();
|
|
|
+ const fields = isSetup
|
|
|
+ ? [
|
|
|
+ { key: "password", label: "设置独立密码", type: "password", placeholder: "8~64位", icon: <Key size={14} style={{ color: B }} /> },
|
|
|
+ { key: "confirm", label: "确认密码", type: "password", placeholder: "再次输入新密码", icon: <Key size={14} style={{ color: B }} /> },
|
|
|
+ ]
|
|
|
+ : [
|
|
|
+ { key: "password", label: "本系统独立密码", type: "password", placeholder: "请输入密码", icon: <Lock size={14} style={{ color: B }} /> },
|
|
|
+ ];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="w-full h-screen flex flex-col overflow-hidden" style={{ fontFamily: "'Noto Sans SC', sans-serif", background: PBG }}>
|
|
|
+ <div className="relative z-30 flex-shrink-0 flex items-center justify-center py-3" style={{ background: B }}>
|
|
|
+ <span className="text-xl font-black tracking-[0.22em] whitespace-nowrap" style={{ color: W }}>方案数据管理</span>
|
|
|
+ </div>
|
|
|
+ <div aria-hidden className="relative z-20 flex-shrink-0"
|
|
|
+ style={{ height: 20, background: `linear-gradient(to bottom, ${B} 0%, ${PBG} 100%)` }} />
|
|
|
+ <div className="flex-1 flex items-center justify-center relative z-10">
|
|
|
+ <div style={{ background: W, width: 400, border: `1px solid rgba(40,118,193,0.2)` }} className="shadow-2xl rounded-sm overflow-hidden">
|
|
|
+ <div className="h-1" style={{ background: `linear-gradient(90deg, ${N}, ${B})` }} />
|
|
|
+ <div className="px-10 py-10">
|
|
|
+ <div className="flex flex-col items-center mb-8">
|
|
|
+ <div className="w-14 h-14 rounded-full flex items-center justify-center mb-4" style={{ background: N }}>
|
|
|
+ {isSetup ? <Key size={22} style={{ color: W }} /> : <Lock size={22} style={{ color: W }} />}
|
|
|
+ </div>
|
|
|
+ <h2 className="text-lg font-black tracking-widest" style={{ color: N }}>{isSetup ? "设置独立密码" : "门户登录"}</h2>
|
|
|
+ <p className="text-sm font-bold mt-1 text-center" style={{ color: `rgba(26,82,153,0.4)` }}>
|
|
|
+ {isSetup ? `首次从门户进入,请为 ${realName} 设置本系统独立密码` : `门户身份已确认:${realName},请输入本系统独立密码`}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div className="mb-4 flex items-center gap-2 px-3 py-2 rounded-sm" style={{ background: `rgba(40,118,193,0.04)`, border: `1px solid rgba(26,82,153,0.2)` }}>
|
|
|
+ <User size={14} style={{ color: B }} />
|
|
|
+ <span className="text-sm font-black" style={{ color: N }}>{username}</span>
|
|
|
+ </div>
|
|
|
+ {error && (
|
|
|
+ <div className="mb-4 flex items-center gap-2 px-3 py-2 rounded-sm" style={{ background: `rgba(40,118,193,0.08)`, border: `1px solid rgba(26,82,153,0.2)` }}>
|
|
|
+ <AlertCircle size={14} style={{ color: B }} />
|
|
|
+ <div>
|
|
|
+ <span className="text-sm font-black" style={{ color: N }}>{error}</span>
|
|
|
+ {errorRequestId && <div className="mt-1 text-[11px] font-bold" style={{ color: `rgba(26,82,153,0.55)` }}>错误编号:{errorRequestId}</div>}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <div className="space-y-4">
|
|
|
+ {fields.map(f => (
|
|
|
+ <div key={f.key}>
|
|
|
+ <label className="text-sm font-black mb-1.5 block" style={{ color: N }}>{f.label}</label>
|
|
|
+ <div className="flex items-center gap-3 rounded-sm border h-11 px-3 transition-colors" style={{ borderColor: "rgba(26,82,153,0.2)", background: `rgba(40,118,193,0.04)` }}>
|
|
|
+ {f.icon}
|
|
|
+ <input type={f.type} disabled={submitting} className="flex-1 text-sm bg-transparent outline-none font-bold disabled:cursor-not-allowed" style={{ color: N }}
|
|
|
+ placeholder={f.placeholder}
|
|
|
+ value={f.key === "password" ? password : confirmPassword}
|
|
|
+ onChange={e => {
|
|
|
+ f.key === "password" ? setPassword(e.target.value) : setConfirmPassword(e.target.value);
|
|
|
+ setError(""); setErrorRequestId("");
|
|
|
+ }}
|
|
|
+ onKeyDown={e => { if (e.key === "Enter") handlePrimary(); }} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <label className="flex items-center gap-2 cursor-pointer">
|
|
|
+ <input type="checkbox" disabled={submitting} checked={keepSignedIn} onChange={e => setKeepSignedIn(e.target.checked)} className="accent-[#2876C1]" />
|
|
|
+ <span className="text-sm font-bold" style={{ color: `rgba(26,82,153,0.55)` }}>保持登录</span>
|
|
|
+ </label>
|
|
|
+ <button onClick={onFallback} className="text-xs font-bold hover:opacity-70" style={{ color: `rgba(26,82,153,0.5)` }}>使用账号密码登录</button>
|
|
|
+ </div>
|
|
|
+ <button onClick={handlePrimary} disabled={submitting} aria-busy={submitting}
|
|
|
+ className="w-full h-11 font-black text-base rounded-sm transition-opacity hover:opacity-80 active:opacity-60 disabled:opacity-60 disabled:cursor-not-allowed tracking-widest mt-2"
|
|
|
+ style={{ background: N, color: W }}>
|
|
|
+ {submitting ? "处理中…" : (isSetup ? "设置密码并登录" : "登 录")}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <p className="text-center text-xs font-bold mt-6" style={{ color: `rgba(26,82,153,0.3)` }}>本系统为涉密系统 · 严禁未授权访问</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="relative z-10 pb-6 text-center text-xs font-bold" style={{ color: `rgba(26,82,153,0.3)` }}>
|
|
|
+ © 方案数据管理 · 版权所有 · 严禁扩散
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function AuthStateScreen({ title, description, requestId, primaryLabel, onPrimary, secondaryLabel, onSecondary, busy }: {
|
|
|
title: string;
|
|
|
description: string;
|
|
|
@@ -2770,6 +2952,14 @@ const RELATION_TABS = [
|
|
|
] as const;
|
|
|
type RelationTabId = (typeof RELATION_TABS)[number]["id"];
|
|
|
|
|
|
+/** 定位目标对应的关系面板页签:附件→其类型页签,主案/子案→配套方案(默认)。 */
|
|
|
+function locateRelationTab(item: DocumentSummary): RelationTabId {
|
|
|
+ if (item.documentType === DocumentType.ATTACHMENT && item.attachmentType) {
|
|
|
+ return item.attachmentType;
|
|
|
+ }
|
|
|
+ return "sub";
|
|
|
+}
|
|
|
+
|
|
|
function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, currentUser }: AuthenticatedPageProps) {
|
|
|
const [categoryId, setCategoryId] = useState<string | null>(null);
|
|
|
const [allCategoryCount, setAllCategoryCount] = useState<number | null>(null);
|
|
|
@@ -2862,12 +3052,13 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
}, [lowerTab]);
|
|
|
|
|
|
// 定位跳转:清空检索退出过渡视图,跳到主案分类并选中主案展开关系面板
|
|
|
- const jumpToMainPlan = (main: DocumentSummary) => {
|
|
|
+ const jumpToMainPlan = (main: DocumentSummary, tab: RelationTabId = "sub") => {
|
|
|
resetGlobalSearch();
|
|
|
setCategoryId(main.categoryId);
|
|
|
setSelected(main);
|
|
|
setPreview(null);
|
|
|
setHideRelations(false);
|
|
|
+ setLowerTab(tab);
|
|
|
setPage(1);
|
|
|
setSearchPage(1);
|
|
|
setLocateError(null);
|
|
|
@@ -2876,16 +3067,17 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
setLocateError(null);
|
|
|
try {
|
|
|
const resolution = await resolveLocateTarget(item, searchResults.items);
|
|
|
- if (resolution.kind === "main") jumpToMainPlan(resolution.main);
|
|
|
+ if (resolution.kind === "main") jumpToMainPlan(resolution.main, locateRelationTab(item));
|
|
|
else setLocateCandidates(resolution);
|
|
|
} catch (error) {
|
|
|
setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
|
|
|
}
|
|
|
};
|
|
|
const confirmLocate = async (mainPlanId: string) => {
|
|
|
+ const tab = locateCandidates ? locateRelationTab(locateCandidates.attachment) : "sub";
|
|
|
setLocateCandidates(null);
|
|
|
try {
|
|
|
- jumpToMainPlan(await documentApi.detail(mainPlanId));
|
|
|
+ jumpToMainPlan(await documentApi.detail(mainPlanId), tab);
|
|
|
} catch (error) {
|
|
|
setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
|
|
|
}
|
|
|
@@ -2928,7 +3120,7 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
- {!hideRelations && selected?.documentType === DocumentType.MAIN && (
|
|
|
+ {!hideRelations && !searchActive && selected?.documentType === DocumentType.MAIN && (
|
|
|
<div className="h-[390px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
|
|
|
<div className="flex border-b" style={{ borderColor: "rgba(40,118,193,.14)" }}>
|
|
|
{RELATION_TABS.map((item) => (
|
|
|
@@ -3049,11 +3241,12 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
|
|
|
// 定位跳转:清空检索退出过渡视图,跳到主案分类并选中主案展开关系面板
|
|
|
// 后台页 detail 受可见性限制,失败时保留原始错误信息提示
|
|
|
- const jumpToMainPlan = (main: DocumentSummary) => {
|
|
|
+ const jumpToMainPlan = (main: DocumentSummary, tab: RelationTabId = "sub") => {
|
|
|
resetGlobalSearch();
|
|
|
setCategoryId(main.categoryId);
|
|
|
setSelected(main);
|
|
|
setPreview(null);
|
|
|
+ setRelationTab(tab);
|
|
|
setPlanPage(1);
|
|
|
setSearchPage(1);
|
|
|
setLocateError(null);
|
|
|
@@ -3062,16 +3255,17 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
setLocateError(null);
|
|
|
try {
|
|
|
const resolution = await resolveLocateTarget(item, searchResults.items);
|
|
|
- if (resolution.kind === "main") jumpToMainPlan(resolution.main);
|
|
|
+ if (resolution.kind === "main") jumpToMainPlan(resolution.main, locateRelationTab(item));
|
|
|
else setLocateCandidates(resolution);
|
|
|
} catch (error) {
|
|
|
setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
|
|
|
}
|
|
|
};
|
|
|
const confirmLocate = async (mainPlanId: string) => {
|
|
|
+ const tab = locateCandidates ? locateRelationTab(locateCandidates.attachment) : "sub";
|
|
|
setLocateCandidates(null);
|
|
|
try {
|
|
|
- jumpToMainPlan(await documentApi.detail(mainPlanId));
|
|
|
+ jumpToMainPlan(await documentApi.detail(mainPlanId), tab);
|
|
|
} catch (error) {
|
|
|
setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
|
|
|
}
|
|
|
@@ -3146,7 +3340,7 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
- {selected?.documentType === DocumentType.MAIN && (
|
|
|
+ {!searchActive && selected?.documentType === DocumentType.MAIN && (
|
|
|
<div className="h-[400px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
|
|
|
<div className="flex border-b" style={{ borderColor: "rgba(40,118,193,.14)" }}>
|
|
|
{RELATION_TABS.map((item) => (
|
|
|
@@ -3206,6 +3400,8 @@ export default function App() {
|
|
|
const [authNotice, setAuthNotice] = useState("");
|
|
|
const [recoveryError, setRecoveryError] = useState<{ message: string; requestId: string } | null>(null);
|
|
|
const [logoutPending, setLogoutPending] = useState(false);
|
|
|
+ // 门户跳转:地址栏带query参数进入时优先走门户验签流程(可回落普通登录)
|
|
|
+ const [portalLaunch, setPortalLaunch] = useState(() => window.location.search.length > 1);
|
|
|
|
|
|
const restoreCurrentSession = useCallback(async () => {
|
|
|
if (!tokenStore.getAccessToken()) {
|
|
|
@@ -3289,6 +3485,15 @@ export default function App() {
|
|
|
setAuthStatus("authenticated");
|
|
|
};
|
|
|
|
|
|
+ const handlePortalSetupLogin = async (ticket: string, newPassword: string, keepSignedIn: boolean) => {
|
|
|
+ const user = await setupPortalPasswordSession(ticket, newPassword, keepSignedIn);
|
|
|
+ setCurrentUser(user);
|
|
|
+ setPage(getFirstAllowedPage(user.allowedModules) ?? "login");
|
|
|
+ setAuthNotice("");
|
|
|
+ setRecoveryError(null);
|
|
|
+ setAuthStatus("authenticated");
|
|
|
+ };
|
|
|
+
|
|
|
const handleLogout = async () => {
|
|
|
if (logoutPending) return;
|
|
|
setLogoutPending(true);
|
|
|
@@ -3347,6 +3552,16 @@ export default function App() {
|
|
|
}
|
|
|
|
|
|
if (authStatus === "unauthenticated" || !currentUser) {
|
|
|
+ if (portalLaunch) {
|
|
|
+ return (
|
|
|
+ <PortalLaunchScreen
|
|
|
+ query={window.location.search}
|
|
|
+ onLogin={handleLogin}
|
|
|
+ onSetupLogin={handlePortalSetupLogin}
|
|
|
+ onFallback={() => setPortalLaunch(false)}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
return <LoginPage onLogin={handleLogin} notice={authNotice} />;
|
|
|
}
|
|
|
|