|
|
@@ -40,7 +40,7 @@ import {
|
|
|
import { authApi } from "../features/auth/authApi";
|
|
|
import { portalAuthApi } from "../features/auth/portalAuthApi";
|
|
|
import { setTheme, UI_THEMES, useUiTheme, type UiTheme } from "../shared/theme/themeStore";
|
|
|
-import type { LoginRequest, PortalOrganizationOption, UserSummary } from "../features/auth/authTypes";
|
|
|
+import type { CaptchaData, LoginRequest, PortalOrganizationOption, UserSummary } from "../features/auth/authTypes";
|
|
|
import { CategoryType, EnabledStatus } from "../shared/api/enums";
|
|
|
import { categoryApi } from "../features/categories/categoryApi";
|
|
|
import {
|
|
|
@@ -129,6 +129,7 @@ import { useDictionaries } from "../features/dictionaries/dictionaryContext";
|
|
|
|
|
|
const N = "var(--dms-deep)"; // 深主色 — all text, never black
|
|
|
const B = "var(--dms-bright)"; // 亮主色 — header, buttons, accents
|
|
|
+const H = "var(--dms-highlight)"; // 高亮点缀色 — 上传区/徽标/统计数值;红主题为国徽金,蓝绿映射各自主色
|
|
|
const W = "#FFFFFF"; // white — cards, canvas
|
|
|
const PBG = "var(--dms-canvas)"; // page background
|
|
|
|
|
|
@@ -864,7 +865,7 @@ function CategoryEditModal({ title, mode, target, tree, submitting, externalErro
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
|
|
|
<div style={{ background: W, width: 420 }} className="rounded-sm shadow-2xl">
|
|
|
<div style={{ background: N, color: W }} className="h-10 flex items-center justify-between px-4 rounded-t-sm">
|
|
|
<span className="text-base font-black tracking-wide">{title}</span>
|
|
|
@@ -951,8 +952,15 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
|
|
|
// 拖动中不实时改面板宽度(避免分类树反复重排),只移动全屏蓝色指示线,
|
|
|
// 松手时一次性提交最终宽度。
|
|
|
const MIN_PANEL_WIDTH = 220;
|
|
|
- const DEFAULT_PANEL_WIDTH = 272;
|
|
|
- const [panelWidth, setPanelWidth] = useState<number>(DEFAULT_PANEL_WIDTH);
|
|
|
+ // 默认宽度取窗口的 1/4,并收敛到 [最小宽, 窗口宽-640](与拖拽上限一致,
|
|
|
+ // 为右侧内容区留足空间);仅首次进入时计算,手动拖宽后以用户值为准
|
|
|
+ const [panelWidth, setPanelWidth] = useState<number>(() =>
|
|
|
+ Math.round(
|
|
|
+ Math.min(
|
|
|
+ Math.max(window.innerWidth / 4, MIN_PANEL_WIDTH),
|
|
|
+ Math.max(MIN_PANEL_WIDTH, window.innerWidth - 640),
|
|
|
+ ),
|
|
|
+ ));
|
|
|
const panelEl = useRef<HTMLDivElement | null>(null);
|
|
|
const guideEl = useRef<HTMLDivElement | null>(null);
|
|
|
const dragState = useRef({ active: false, x: 0, width: 0, latest: 0 });
|
|
|
@@ -984,7 +992,7 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
|
|
|
className="absolute right-0 top-0 bottom-0 z-10 w-2.5 -mr-[5px] cursor-col-resize transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.35)]"
|
|
|
onPointerDown={(event) => {
|
|
|
if (event.button !== 0) return;
|
|
|
- const width = panelEl.current?.offsetWidth ?? DEFAULT_PANEL_WIDTH;
|
|
|
+ const width = panelEl.current?.offsetWidth ?? panelWidth;
|
|
|
dragState.current = { active: true, x: event.clientX, width, latest: width };
|
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
|
if (guideEl.current) {
|
|
|
@@ -1056,7 +1064,7 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
|
|
|
// ─── Word viewer modal ────────────────────────────────────────────────────────
|
|
|
function WordViewer({ doc, onClose }: { doc: Doc; onClose: () => void }) {
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
|
|
|
<div style={{ background: "#D6E8F6", width: 900, height: "88vh" }} className="flex flex-col" onClick={e => e.stopPropagation()}>
|
|
|
{/* Title bar — government blue */}
|
|
|
<div style={{ background: B, color: W }} className="h-10 flex items-center justify-between px-4 flex-shrink-0">
|
|
|
@@ -1111,7 +1119,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
|
|
|
const inputStyle = { borderColor: "rgba(var(--dms-deep-rgb),0.2)", color: N };
|
|
|
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
|
|
|
<div style={{ background: W, width: 660, border: `1px solid rgba(11,37,69,0.15)` }} className="rounded-sm shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
|
<div style={{ background: N, color: W }} className="h-11 flex items-center justify-between px-5 rounded-t-sm">
|
|
|
<span className="text-base font-black tracking-widest">上传文档</span>
|
|
|
@@ -1119,7 +1127,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
|
|
|
</div>
|
|
|
<div className="p-6 space-y-4">
|
|
|
<div
|
|
|
- style={{ border: `2px dashed ${dragging ? B : "rgba(var(--dms-deep-rgb),0.25)"}`, background: dragging ? `rgba(var(--dms-primary-rgb),0.05)` : `rgba(var(--dms-line-rgb),0.04)` }}
|
|
|
+ style={{ border: `2px dashed ${dragging ? H : "rgba(var(--dms-deep-rgb),0.25)"}`, background: dragging ? `rgba(var(--dms-primary-rgb),0.05)` : `rgba(var(--dms-line-rgb),0.04)` }}
|
|
|
className="rounded-sm h-28 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all"
|
|
|
onDragOver={e => { e.preventDefault(); setDragging(true); }}
|
|
|
onDragLeave={() => setDragging(false)}
|
|
|
@@ -1127,8 +1135,8 @@ function UploadModal({ onClose }: { onClose: () => void }) {
|
|
|
onClick={() => setUploaded(true)}
|
|
|
>
|
|
|
{uploaded
|
|
|
- ? <><Check size={22} style={{ color: B }} /><span className="text-base font-black" style={{ color: B }}>文件已选择:综合应急预案.docx</span></>
|
|
|
- : <><Upload size={22} style={{ color: B }} /><span className="text-base font-black" style={{ color: N }}>点击选择或拖拽文件至此处</span><span className="text-sm font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.4)` }}>支持 .doc .docx .pdf .xls 格式</span></>
|
|
|
+ ? <><Check size={22} style={{ color: H }} /><span className="text-base font-black" style={{ color: H }}>文件已选择:综合应急预案.docx</span></>
|
|
|
+ : <><Upload size={22} style={{ color: H }} /><span className="text-base font-black" style={{ color: N }}>点击选择或拖拽文件至此处</span><span className="text-sm font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.4)` }}>支持 .doc .docx .pdf .xls 格式</span></>
|
|
|
}
|
|
|
</div>
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
@@ -1198,7 +1206,7 @@ function BatchImportModal({ onClose }: { onClose: () => void }) {
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
|
|
|
<div style={{ background: W, width: 660, border: `1px solid rgba(11,37,69,0.15)` }} className="rounded-sm shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
|
<div style={{ background: N, color: W }} className="h-11 flex items-center justify-between px-5 rounded-t-sm">
|
|
|
<span className="text-base font-black tracking-widest">批量导入文档</span>
|
|
|
@@ -1208,7 +1216,7 @@ function BatchImportModal({ onClose }: { onClose: () => void }) {
|
|
|
{/* Drop zone */}
|
|
|
<div
|
|
|
style={{
|
|
|
- border: `2px dashed ${dragging ? B : "rgba(var(--dms-deep-rgb),0.25)"}`,
|
|
|
+ border: `2px dashed ${dragging ? H : "rgba(var(--dms-deep-rgb),0.25)"}`,
|
|
|
background: dragging ? `rgba(var(--dms-primary-rgb),0.06)` : `rgba(var(--dms-line-rgb),0.04)`,
|
|
|
}}
|
|
|
className="rounded-sm h-32 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all mb-4"
|
|
|
@@ -1224,7 +1232,7 @@ function BatchImportModal({ onClose }: { onClose: () => void }) {
|
|
|
el.click();
|
|
|
}}
|
|
|
>
|
|
|
- <Upload size={28} style={{ color: dragging ? B : `rgba(var(--dms-deep-rgb),0.3)` }} />
|
|
|
+ <Upload size={28} style={{ color: dragging ? H : `rgba(var(--dms-deep-rgb),0.3)` }} />
|
|
|
<span className="text-base font-black" style={{ color: N }}>拖拽多个文件至此处,或点击选择</span>
|
|
|
<span className="text-sm font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.4)` }}>支持 .doc .docx .pdf .xls .xlsx,可同时导入多个</span>
|
|
|
</div>
|
|
|
@@ -1349,7 +1357,7 @@ function PermissionModal({ doc, onClose }: { doc: Doc; onClose: () => void }) {
|
|
|
});
|
|
|
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
|
|
|
<div style={{ background: W, width: 540, border: `1px solid rgba(11,37,69,0.15)` }} className="rounded-sm shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
|
<div style={{ background: N, color: W }} className="h-11 flex items-center justify-between px-5 rounded-t-sm">
|
|
|
<span className="text-base font-black tracking-widest">借阅授权 — {doc.name}</span>
|
|
|
@@ -1463,7 +1471,7 @@ async function persistUiTheme(theme: UiTheme): Promise<void> {
|
|
|
/** 主题预览色样:固定色值(选择界面本身不随主题变),取各主题强调色。 */
|
|
|
const THEME_SWATCHES: Record<UiTheme, string> = {
|
|
|
blue: "#2060C8",
|
|
|
- red: "#C7000B",
|
|
|
+ red: "#C8102E",
|
|
|
green: "#228B5E",
|
|
|
};
|
|
|
|
|
|
@@ -1530,7 +1538,9 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
|
|
|
|
|
|
return (
|
|
|
<div className="w-full h-screen flex flex-col overflow-hidden" style={{ fontFamily: "'Noto Sans SC', sans-serif", background: PBG }}>
|
|
|
- {/* Header — auto height from padding, fits menu naturally;z-45 需高于主内容区 z-40,否则用户卡片下拉被遮挡 */}
|
|
|
+ {/* Header — auto height from padding, fits menu naturally;z-[45] 高于主内容普通元素,
|
|
|
+ 人员下拉卡片(z-50,本上下文内)不被主内容遮挡;主内容不建层叠上下文,
|
|
|
+ 业务弹窗 fixed z-[80]+ 反过来可覆盖横幅 */}
|
|
|
<div className="relative z-[45] flex-shrink-0 flex items-center justify-between px-8 py-3" style={{ background: "var(--dms-banner)" }}>
|
|
|
{/* Left: 设置界面时为返回按钮;常规为人员下拉(基本信息 + 设置入口) */}
|
|
|
{settingsOpen ? (
|
|
|
@@ -1572,9 +1582,11 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
|
|
|
</div>
|
|
|
)}
|
|
|
{/* Center: title */}
|
|
|
- <h1 className="absolute left-1/2 -translate-x-1/2 font-black tracking-[0.18em] whitespace-nowrap"
|
|
|
+ <h1 className="absolute left-1/2 -translate-x-1/2 font-black tracking-[0.18em] whitespace-nowrap flex items-center gap-3"
|
|
|
style={{ color: W, fontSize: 30 }}>
|
|
|
+ <span aria-hidden className="inline-block w-8 h-[3px] rounded-full" style={{ background: "var(--dms-highlight-gradient)" }} />
|
|
|
{settingsOpen ? "设置" : title}
|
|
|
+ <span aria-hidden className="inline-block w-8 h-[3px] rounded-full" style={{ background: "var(--dms-highlight-gradient)" }} />
|
|
|
</h1>
|
|
|
{/* Right: nav — all items horizontal, never wrap */}
|
|
|
<div className="flex items-center gap-0.5">
|
|
|
@@ -1599,8 +1611,11 @@ function PageFrame({ title, current, navigationItems, onNav, onLogout, logoutPen
|
|
|
<div aria-hidden className="relative z-20 flex-shrink-0"
|
|
|
style={{ height: 20, background: `linear-gradient(to bottom, var(--dms-deep) 0%, ${PBG} 100%)` }} />
|
|
|
|
|
|
- {/* Main content — sits below header;设置界面时替换为主题设置面板 */}
|
|
|
- <div className="relative z-40 flex flex-1 min-h-0">
|
|
|
+ {/* Main content — sits below header;设置界面时替换为主题设置面板。
|
|
|
+ 不设 z-index(relative + z-auto 不创建层叠上下文):页面内业务弹窗
|
|
|
+ fixed z-[80]+ 参与根上下文比较,可完整覆盖顶部横幅 z-[45];人员
|
|
|
+ 下拉卡片(z-50,位于横幅上下文内)仍高于主内容普通元素不被遮挡 */}
|
|
|
+ <div className="relative flex flex-1 min-h-0">
|
|
|
{settingsOpen ? <SettingsPanel onPersist={persistUiTheme} /> : children}
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -1895,7 +1910,7 @@ function AdminDocRow({ doc, index, selected, childCount, attachmentCount, onSele
|
|
|
<div className="flex items-center gap-2"><FileText size={14} style={{ color: B }} className="flex-shrink-0" /><span className="text-base font-black" style={{ color: N }}>{doc.name}</span></div>
|
|
|
</td>
|
|
|
<td className="px-4 py-3" style={{ background: "inherit" }}>
|
|
|
- <span style={{ color: doc.type === "主案" ? N : B, background: doc.type === "主案" ? `rgba(var(--dms-line-rgb),0.1)` : `rgba(var(--dms-primary-rgb),0.1)`, border: `1px solid ${doc.type === "主案" ? "rgba(var(--dms-deep-rgb),0.2)" : "rgba(var(--dms-primary-rgb),0.25)"}` }} className="text-base font-black px-2 py-0.5 rounded-sm">{doc.type}</span>
|
|
|
+ <span style={{ color: doc.type === "主案" ? N : H, background: doc.type === "主案" ? `rgba(var(--dms-line-rgb),0.1)` : `rgba(var(--dms-highlight-rgb),0.12)`, border: `1px solid ${doc.type === "主案" ? "rgba(var(--dms-deep-rgb),0.2)" : "rgba(var(--dms-highlight-rgb),0.3)"}` }} className="text-base font-black px-2 py-0.5 rounded-sm">{doc.type}</span>
|
|
|
</td>
|
|
|
<td className="px-4 py-3 whitespace-nowrap" style={{ background: "inherit" }}><LevelBadge level={doc.level} /></td>
|
|
|
<td className="px-4 py-3 text-base font-bold" style={{ color: `rgba(var(--dms-deep-rgb),0.6)`, background: "inherit" }}>{doc.visibility}</td>
|
|
|
@@ -2130,7 +2145,7 @@ function MountAttachmentModal({ plan, attachments, mountedIds, onConfirm, onClos
|
|
|
return next;
|
|
|
});
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),0.5)" }} onClick={onClose}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
|
|
|
<div className="w-[780px] max-h-[82vh] flex flex-col rounded-sm shadow-2xl" style={{ background: W }} onClick={e => e.stopPropagation()}>
|
|
|
<div className="h-11 flex items-center justify-between px-5 flex-shrink-0" style={{ background: N, color: W }}>
|
|
|
<span className="text-base font-black">从共享附件库挂载到「{plan.name}」</span>
|
|
|
@@ -2512,16 +2527,38 @@ function LoginPage({ onLogin, notice }: {
|
|
|
const [error, setError] = useState("");
|
|
|
const [errorRequestId, setErrorRequestId] = useState("");
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
+ const [captcha, setCaptcha] = useState<CaptchaData | null>(null);
|
|
|
+ const [captchaCode, setCaptchaCode] = useState("");
|
|
|
const loginBackground = useLoginBackground();
|
|
|
|
|
|
+ // 登录图片验证码(安全需求4):后端开启时显示图片与输入框,点击图片可刷新
|
|
|
+ const refreshCaptcha = useCallback(async () => {
|
|
|
+ setCaptchaCode("");
|
|
|
+ try {
|
|
|
+ setCaptcha(await authApi.fetchCaptcha());
|
|
|
+ } catch {
|
|
|
+ setCaptcha(null);
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ useEffect(() => { void refreshCaptcha(); }, [refreshCaptcha]);
|
|
|
+
|
|
|
const handleLogin = async () => {
|
|
|
if (submitting) return;
|
|
|
if (!username.trim() || !password.trim()) { setError("请输入用户名和密码"); return; }
|
|
|
+ if (captcha?.enabled && !captchaCode.trim()) { setError("请输入验证码"); return; }
|
|
|
setSubmitting(true);
|
|
|
setError("");
|
|
|
setErrorRequestId("");
|
|
|
try {
|
|
|
- await onLogin({ username: username.trim(), password, keepSignedIn });
|
|
|
+ await onLogin({
|
|
|
+ username: username.trim(),
|
|
|
+ password,
|
|
|
+ keepSignedIn,
|
|
|
+ ...(captcha?.enabled && captcha.captchaId
|
|
|
+ ? { captchaId: captcha.captchaId, captchaCode: captchaCode.trim() }
|
|
|
+ : {}),
|
|
|
+ });
|
|
|
} catch (loginError) {
|
|
|
if (loginError instanceof HttpError) {
|
|
|
setError(loginError.message);
|
|
|
@@ -2529,6 +2566,8 @@ function LoginPage({ onLogin, notice }: {
|
|
|
} else {
|
|
|
setError("登录失败,请稍后重试");
|
|
|
}
|
|
|
+ // 尝试过一次即消费:无论成败刷新验证码
|
|
|
+ void refreshCaptcha();
|
|
|
} finally {
|
|
|
setSubmitting(false);
|
|
|
}
|
|
|
@@ -2584,6 +2623,26 @@ function LoginPage({ onLogin, notice }: {
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
+ {captcha?.enabled && (
|
|
|
+ <div>
|
|
|
+ <label className="text-base font-black mb-1.5 block" style={{ color: N }}>验证码</label>
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <div className="dms-input flex-1 flex items-center rounded-sm border h-11 px-3 transition-colors" style={{ borderColor: "rgba(var(--dms-deep-rgb),0.2)", background: `rgba(var(--dms-line-rgb),0.04)` }}>
|
|
|
+ <input type="text" disabled={submitting} maxLength={4} className="flex-1 text-base bg-transparent outline-none font-bold tracking-[0.3em] disabled:cursor-not-allowed" style={{ color: N }}
|
|
|
+ placeholder="请输入验证码"
|
|
|
+ value={captchaCode}
|
|
|
+ onChange={e => { setCaptchaCode(e.target.value); setError(""); setErrorRequestId(""); }}
|
|
|
+ onKeyDown={e => { if (e.key === "Enter") void handleLogin(); }} />
|
|
|
+ </div>
|
|
|
+ {captcha.image && (
|
|
|
+ <img src={captcha.image} alt="验证码" title="点击刷新验证码"
|
|
|
+ onClick={() => { if (!submitting) void refreshCaptcha(); }}
|
|
|
+ className="h-11 rounded-sm cursor-pointer select-none"
|
|
|
+ style={{ border: "1px solid rgba(var(--dms-deep-rgb),0.2)" }} />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
<div className="flex items-center">
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
|
<input type="checkbox" disabled={submitting} checked={keepSignedIn} onChange={e => setKeepSignedIn(e.target.checked)} className="accent-[var(--primary)]" />
|
|
|
@@ -2964,6 +3023,32 @@ function F4DocumentTable({ items, keyword, contentKeyword, selectedId, currentUs
|
|
|
.filter((id) => !mountedRowIds.has(id)),
|
|
|
);
|
|
|
};
|
|
|
+ // 单击/双击区分:单击延迟 250ms 判定,期间同一行第二次点击视为双击,取消单击
|
|
|
+ // (双击仅触发 onDoubleClick 打开查看,不再联动第一次 click 的选中与右侧预览);
|
|
|
+ // 快速点击不同行时旧单击作废,按新行重新计时。
|
|
|
+ const pendingClickRef = useRef<{ timer: number; rowKey: string } | null>(null);
|
|
|
+ const cancelPendingClick = () => {
|
|
|
+ if (pendingClickRef.current) {
|
|
|
+ window.clearTimeout(pendingClickRef.current.timer);
|
|
|
+ pendingClickRef.current = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ useEffect(() => cancelPendingClick, []);
|
|
|
+ const handleRowClick = (item: DocumentSummary | AttachmentBindingSummary) => {
|
|
|
+ const rowKey = bindingMode ? (item as AttachmentBindingSummary).bindingId : item.id;
|
|
|
+ const pending = pendingClickRef.current;
|
|
|
+ if (pending) {
|
|
|
+ cancelPendingClick();
|
|
|
+ if (pending.rowKey === rowKey) return; // 双击的第二次点击:交给 onDoubleClick
|
|
|
+ }
|
|
|
+ pendingClickRef.current = {
|
|
|
+ rowKey,
|
|
|
+ timer: window.setTimeout(() => {
|
|
|
+ pendingClickRef.current = null;
|
|
|
+ onSelect?.(item as DocumentSummary);
|
|
|
+ }, 250),
|
|
|
+ };
|
|
|
+ };
|
|
|
return (
|
|
|
<>
|
|
|
<table className="w-full border-collapse">
|
|
|
@@ -2998,7 +3083,7 @@ function F4DocumentTable({ items, keyword, contentKeyword, selectedId, currentUs
|
|
|
return (
|
|
|
<Fragment key={binding?.bindingId ?? item.id}>
|
|
|
<tr
|
|
|
- onClick={() => onSelect?.(item)}
|
|
|
+ onClick={() => handleRowClick(item)}
|
|
|
onDoubleClick={() => {
|
|
|
if (canView && onView) onView(item);
|
|
|
}}
|
|
|
@@ -3043,14 +3128,14 @@ function F4DocumentTable({ items, keyword, contentKeyword, selectedId, currentUs
|
|
|
{showTypeBadge && (
|
|
|
<div className="mt-1">
|
|
|
<span className="inline-block px-1.5 py-0.5 text-base font-black leading-none" style={{ color: B, border: `1px solid ${B}` }}>
|
|
|
- {item.documentType === DocumentType.MAIN ? topCategoryDisplayName(item.categoryId) ?? "主体方案" : item.documentType === DocumentType.SUB_PLAN ? "配套方案" : dictionaries.label("attachmentTypes", item.attachmentType ?? "") || "配套资料"}
|
|
|
+ {item.documentType === DocumentType.MAIN ? topCategoryDisplayName(item.categoryId) ?? "主体方案" : item.documentType === DocumentType.SUB_PLAN ? "配套方案" : dictionaries.label("attachmentTypes", item.attachmentType ?? "") || "共享资料"}
|
|
|
</span>
|
|
|
</div>
|
|
|
)}
|
|
|
{((item.subHitCount ?? 0) > 0 || (item.attachmentHitCount ?? 0) > 0) && (
|
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
|
- {(item.subHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/附件命中高级检索条件的主案汇总")}>📄 子案 {item.subHitCount}</span>}
|
|
|
- {(item.attachmentHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/附件命中高级检索条件的主案汇总")}>📎 附件 {item.attachmentHitCount}</span>}
|
|
|
+ {(item.subHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/共享资料命中高级检索条件的主案汇总")}>📄 子案 {item.subHitCount}</span>}
|
|
|
+ {(item.attachmentHitCount ?? 0) > 0 && <span className="px-1.5 py-0.5 text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(var(--dms-bright-rgb),.14)" }} {...tipProps("子案/共享资料命中高级检索条件的主案汇总")}>📎 共享资料 {item.attachmentHitCount}</span>}
|
|
|
</div>
|
|
|
)}
|
|
|
</td>
|
|
|
@@ -3159,7 +3244,7 @@ function F4ReverseModal({ attachment, onClose }: { attachment: DocumentSummary;
|
|
|
const state = useAttachmentMainPlans(attachment.id);
|
|
|
const dictionaries = useDictionaries();
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.5)" }}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
|
|
|
<div className="w-[560px] shadow-2xl" style={{ background: W }}>
|
|
|
<div className="h-11 px-5 flex items-center justify-between" style={{ background: N, color: W }}>
|
|
|
<span className="font-black">已挂载主案 · {attachment.documentName}</span>
|
|
|
@@ -3217,7 +3302,7 @@ function LocateTargetModal({ attachment, candidates, onConfirm, onClose }: {
|
|
|
const dictionaries = useDictionaries();
|
|
|
const [chosen, setChosen] = useState<string | null>(null);
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.5)" }}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
|
|
|
<div className="w-[560px] shadow-2xl" style={{ background: W }}>
|
|
|
<div className="h-11 px-5 flex items-center justify-between" style={{ background: N, color: W }}>
|
|
|
<span className="font-black truncate">选择定位主案 · {attachment.documentName}</span>
|
|
|
@@ -3271,7 +3356,7 @@ function MovePositionModal({ targetName, scopeLabel, currentPosition, total, onC
|
|
|
}
|
|
|
};
|
|
|
return (
|
|
|
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.5)" }}>
|
|
|
+ <div className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
|
|
|
<div className="w-[480px] shadow-2xl" style={{ background: W }}>
|
|
|
<div className="h-11 px-5 flex items-center justify-between" style={{ background: N, color: W }}>
|
|
|
<span className="font-black truncate">移动到指定位置 · {targetName}</span>
|
|
|
@@ -3306,9 +3391,6 @@ function MovePositionModal({ targetName, scopeLabel, currentPosition, total, onC
|
|
|
}
|
|
|
|
|
|
function F4AttachmentLibrary({ compact = false, management = false, canManageOrganization = false, onPreview }: { compact?: boolean; management?: boolean; canManageOrganization?: boolean; onPreview?: (item: DocumentSummary) => void }) {
|
|
|
- const dictionaries = useDictionaries();
|
|
|
- const [keyword, setKeyword] = useState("");
|
|
|
- const [attachmentType, setAttachmentType] = useState("");
|
|
|
const [page, setPage] = useState(1);
|
|
|
const [reverse, setReverse] = useState<DocumentSummary | null>(null);
|
|
|
const [localPreview, setLocalPreview] = useState<DocumentSummary | null>(null);
|
|
|
@@ -3316,11 +3398,9 @@ function F4AttachmentLibrary({ compact = false, management = false, canManageOrg
|
|
|
const openPreview = onPreview ?? setLocalPreview;
|
|
|
const [edit, setEdit] = useState<DocumentSummary | null>(null);
|
|
|
const [remove, setRemove] = useState<DocumentSummary | null>(null);
|
|
|
- const [upload, setUpload] = useState(false);
|
|
|
- const [batch, setBatch] = useState(false);
|
|
|
+ // 筛选行与上传/批量导入按钮已上移到管理页顶部页签行(与主体方案一致),
|
|
|
+ // 列表恒为全量倒序,上传弹窗由 AdminPage 持有
|
|
|
const state = useAttachmentList({
|
|
|
- keyword: keyword.trim() || undefined,
|
|
|
- attachmentType: attachmentType ? attachmentType as keyof typeof AttachmentType : undefined,
|
|
|
sortBy: "updatedAt",
|
|
|
sortDirection: "desc",
|
|
|
page,
|
|
|
@@ -3328,19 +3408,6 @@ function F4AttachmentLibrary({ compact = false, management = false, canManageOrg
|
|
|
});
|
|
|
return (
|
|
|
<div className="h-full flex flex-col" style={{ background: W }}>
|
|
|
- <div className="flex items-center gap-2 p-3 border-b" style={{ borderColor: "rgba(var(--dms-line-rgb),.14)" }}>
|
|
|
- <Library size={16} style={{ color: B }} />
|
|
|
- <span className="text-base font-black mr-2" style={{ color: N }}>独立共享资料库</span>
|
|
|
- <input value={keyword} onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索附件名称"
|
|
|
- className="h-8 w-60 px-3 text-base font-bold outline-none border" style={{ color: N, borderColor: "rgba(var(--dms-line-rgb),.25)" }} />
|
|
|
- <select value={attachmentType} onChange={(event) => { setAttachmentType(event.target.value); setPage(1); }}
|
|
|
- className="h-8 px-2 text-base font-bold border" style={{ color: N, borderColor: "rgba(var(--dms-line-rgb),.25)" }}>
|
|
|
- <option value="">全部附件类型</option>
|
|
|
- {dictionaries.options("attachmentTypes").map((option) => <option key={option.code} value={option.code}>{option.label}</option>)}
|
|
|
- </select>
|
|
|
- <span className="ml-auto text-base font-bold" style={{ color: N }}>共 {state.data?.total ?? 0} 项</span>
|
|
|
- {management && <><button onClick={() => setUpload(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传附件</button><button onClick={() => setBatch(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入</button></>}
|
|
|
- </div>
|
|
|
<div className="relative flex-1 min-h-0">
|
|
|
{state.loading && <F4LoadingOverlay />}
|
|
|
<div className="h-full overflow-auto">
|
|
|
@@ -3369,8 +3436,6 @@ function F4AttachmentLibrary({ compact = false, management = false, canManageOrg
|
|
|
{fullView && <FilePreviewModal item={fullView} mode="modal" onClose={() => setFullView(null)} />}
|
|
|
{edit && <EditMetadataModal item={edit} canManageOrganization={canManageOrganization} onClose={() => setEdit(null)} />}
|
|
|
{remove && <DeleteDocumentModal item={remove} onClose={() => setRemove(null)} onDeleted={() => { setReverse(null); }} />}
|
|
|
- {upload && <SingleUploadModal kind="attachment" onClose={() => setUpload(false)} />}
|
|
|
- {batch && <F5BatchImportModal kind="attachment" onClose={() => setBatch(false)} />}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
@@ -3583,7 +3648,7 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
)}
|
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b text-base font-bold whitespace-nowrap" style={{ borderColor: "rgba(var(--dms-line-rgb),.14)", color: N }}>
|
|
|
<span className="font-black">全局检索结果 · 共 {searchPageState.total} 项</span>
|
|
|
- <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 附件 {searchStats.attachment}</span>
|
|
|
+ <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 共享资料 {searchStats.attachment}</span>
|
|
|
{searchResults.truncated && <span style={{ color: "#C0392B" }}>命中超过 1000 条,仅展示前 1000 条</span>}
|
|
|
</div>
|
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
|
@@ -3663,6 +3728,10 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
const [remove, setRemove] = useState<DocumentSummary | null>(null);
|
|
|
const [uploadDocument, setUploadDocument] = useState(false);
|
|
|
const [batchDocuments, setBatchDocuments] = useState(false);
|
|
|
+ // 共享资料库的上传/批量导入:按钮已上移到顶部页签行(与主体方案一致),
|
|
|
+ // 弹窗状态由本页持有,F4AttachmentLibrary 不再自带工具栏入口
|
|
|
+ const [uploadAttachmentLib, setUploadAttachmentLib] = useState(false);
|
|
|
+ const [batchAttachmentLib, setBatchAttachmentLib] = useState(false);
|
|
|
const [bindingPlan, setBindingPlan] = useState<DocumentSummary | null>(null);
|
|
|
const [permission, setPermission] = useState<DocumentSummary | null>(null);
|
|
|
const [unbind, setUnbind] = useState<AttachmentBindingSummary | null>(null);
|
|
|
@@ -3859,13 +3928,16 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
<PageFrame title="方案数据管理" current="admin" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
|
|
|
{mode === "plans" ? <LeftPanel allCount={allCategoryCount} admin={isAdmin && !!currentUser.permCategoryEdit} selectedCategoryId={categoryId} onSelectCategory={(id) => { resetGlobalSearch(); setCategoryId(id); setPlanPage(1); setSelected(null); setPreview(null); }} /> : <div className="w-[272px] m-3 p-5 flex-shrink-0" style={{ background: W, color: N }}><Library size={22} className="mb-3" /><div className="font-black">共享资料</div></div>}
|
|
|
<div ref={centerZoom.ref} className="flex-1 flex flex-col m-3 ml-0 min-h-0" style={{ zoom: centerZoom.zoom }}>
|
|
|
- {isAdmin && <DocumentStatisticsCards />}
|
|
|
+ {/* 统计接口按日志审计管理位鉴权(audit_log_required),前端同步按该权限位显隐,避免无该权限的管理员看到"统计数据加载失败" */}
|
|
|
+ {!!currentUser.permAuditLog && <DocumentStatisticsCards />}
|
|
|
<GlobalSearchBar ariaLabel="后台全局检索" />
|
|
|
<div className="flex items-center mb-2" style={{ background: W, border: "1px solid rgba(var(--dms-line-rgb),.18)" }}>
|
|
|
<button onClick={() => { resetGlobalSearch(); setMode("plans"); }} className="px-4 py-3 text-base font-black" style={{ color: mode === "plans" ? W : N, background: mode === "plans" ? N : W }}>主体方案</button>
|
|
|
{isAdmin && (<button onClick={() => { resetGlobalSearch(); setMode("attachments"); }} className="px-4 py-3 text-base font-black" style={{ color: mode === "attachments" ? W : N, background: mode === "attachments" ? N : W }}>共享资料库</button>)}
|
|
|
<div className="ml-auto flex gap-2 mr-3">
|
|
|
- {isAdmin && mode === "plans" && <><button onClick={() => setUploadDocument(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传主体方案</button><button onClick={() => setBatchDocuments(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入方案</button></>}
|
|
|
+ {/* 上传/批量导入按方案权限矩阵上传位门控(契约1.31):后端 createRecord 同步校验 */}
|
|
|
+ {mode === "plans" && !!currentUser.permUpload && <><button onClick={() => setUploadDocument(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传主体方案</button><button onClick={() => setBatchDocuments(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入方案</button></>}
|
|
|
+ {mode === "attachments" && !!currentUser.permUpload && <><button onClick={() => setUploadAttachmentLib(true)} className="px-3 py-1.5 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>上传共享资料</button><button onClick={() => setBatchAttachmentLib(true)} className="px-3 py-1.5 text-base font-black border" style={{ color: N, borderColor: B }}>批量导入</button></>}
|
|
|
</div>
|
|
|
</div>
|
|
|
{searchActive ? (
|
|
|
@@ -3879,7 +3951,7 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
)}
|
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b text-base font-bold whitespace-nowrap" style={{ borderColor: "rgba(var(--dms-line-rgb),.14)", color: N }}>
|
|
|
<span className="font-black">全局检索结果 · 共 {searchPageState.total} 项</span>
|
|
|
- <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 附件 {searchStats.attachment}</span>
|
|
|
+ <span>主案 {searchStats.main} / 子案 {searchStats.sub} / 共享资料 {searchStats.attachment}</span>
|
|
|
{searchResults.truncated && <span style={{ color: "#C0392B" }}>命中超过 1000 条,仅展示前 1000 条</span>}
|
|
|
</div>
|
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
|
@@ -3926,7 +3998,6 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
{selected.allowedActions.includes(AllowedAction.BIND_ATTACHMENT) && <button onClick={() => setBindingPlan(selected)} className="ml-2 my-1.5 px-3 text-base font-black text-white" style={{ background: "var(--dms-primary-gradient)" }}>挂载</button>}
|
|
|
</>
|
|
|
)}
|
|
|
- {isAdmin && selected.allowedActions.includes(AllowedAction.CONFIG_PERMISSION) && <button onClick={() => setPermission(selected)} className="my-1.5 ml-2 px-3 text-base font-black border" style={{ color: N, borderColor: B }}>借阅授权</button>}
|
|
|
</div>
|
|
|
<div className="relative flex-1 min-h-0">
|
|
|
{mounted.loading && <F4LoadingOverlay />}
|
|
|
@@ -3949,6 +4020,8 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
{uploadDocument && <SingleUploadModal kind="document" mainOnly initialCategoryId={categoryId ?? undefined} onClose={() => setUploadDocument(false)} />}
|
|
|
{uploadAttachmentTab && <SingleUploadModal kind="attachment" initialAttachmentType={uploadAttachmentTab} onUploaded={(detail) => { void handleAttachmentUploaded(detail); }} onClose={() => setUploadAttachmentTab(null)} />}
|
|
|
{batchDocuments && <F5BatchImportModal kind="document" onClose={() => setBatchDocuments(false)} />}
|
|
|
+ {uploadAttachmentLib && <SingleUploadModal kind="attachment" onClose={() => setUploadAttachmentLib(false)} />}
|
|
|
+ {batchAttachmentLib && <F5BatchImportModal kind="attachment" onClose={() => setBatchAttachmentLib(false)} />}
|
|
|
{bindingPlan && (
|
|
|
<AttachmentBindingModal
|
|
|
mainPlan={bindingPlan}
|
|
|
@@ -4144,13 +4217,19 @@ export default function App() {
|
|
|
void handleLogout("长时间未操作,已自动退出登录");
|
|
|
});
|
|
|
|
|
|
- const navigationItems =
|
|
|
+ const navigationItems = (
|
|
|
currentUser && dictionaries.status === "success"
|
|
|
? getNavigationItems(
|
|
|
currentUser.allowedModules,
|
|
|
(module) => dictionaries.label("allowedModules", module),
|
|
|
)
|
|
|
- : [];
|
|
|
+ : []
|
|
|
+ ).filter(
|
|
|
+ // 「机构用户」入口按三管理位联合显示(契约1.31):无任何管理位时不渲染该菜单
|
|
|
+ (item) =>
|
|
|
+ item.page !== "directory" ||
|
|
|
+ !!(currentUser?.permOrgEdit || currentUser?.permUserEdit || currentUser?.permRoleEdit),
|
|
|
+ );
|
|
|
const handleNavigation = (target: AuthenticatedPage) => {
|
|
|
if (!currentUser) return;
|
|
|
if (isPageAllowed(target, currentUser.allowedModules)) {
|
|
|
@@ -4266,7 +4345,8 @@ export default function App() {
|
|
|
<AuditDashboard />
|
|
|
</PageFrame>
|
|
|
)}
|
|
|
- {page === "directory" && currentUser.roleCode === RoleCode.ADMIN && (
|
|
|
+ {page === "directory" && currentUser.roleCode === RoleCode.ADMIN
|
|
|
+ && !!(currentUser.permOrgEdit || currentUser.permUserEdit || currentUser.permRoleEdit) && (
|
|
|
<PageFrame title="方案数据管理" current="directory" {...pageProps}>
|
|
|
<DirectoryPage currentUser={currentUser} />
|
|
|
</PageFrame>
|