|
|
@@ -1,4 +1,4 @@
|
|
|
-import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
+import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
|
|
import {
|
|
|
Search, ChevronRight, ChevronDown, Download, Eye, Upload, Trash2, X,
|
|
|
Edit2, Key, User, Activity, LogOut, Plus, RefreshCw, Check, Lock,
|
|
|
@@ -54,8 +54,10 @@ import { documentApi } from "../features/documents/documentApi";
|
|
|
import {
|
|
|
loadDetailOnce,
|
|
|
useDocumentList,
|
|
|
+ useDocumentTags,
|
|
|
useSubPlanList,
|
|
|
} from "../features/documents/documentState";
|
|
|
+import { TagPanel } from "../features/documents/TagPanel";
|
|
|
import type {
|
|
|
DocumentDetail,
|
|
|
DocumentSummary,
|
|
|
@@ -665,12 +667,14 @@ function LeftPanel({ children, admin, selectedCategoryId, onSelectCategory, allC
|
|
|
<span className="text-sm font-black" style={{ color: N }}>方案分类</span>
|
|
|
</div>
|
|
|
<div className="flex-1 overflow-hidden flex flex-col p-3">
|
|
|
- <CategoryPanel
|
|
|
- admin={admin}
|
|
|
- selectedCategoryId={selectedCategoryId}
|
|
|
- onSelectCategory={onSelectCategory}
|
|
|
- allCount={allCount}
|
|
|
- />
|
|
|
+ <div className="flex-1 min-h-0 flex flex-col">
|
|
|
+ <CategoryPanel
|
|
|
+ admin={admin}
|
|
|
+ selectedCategoryId={selectedCategoryId}
|
|
|
+ onSelectCategory={onSelectCategory}
|
|
|
+ allCount={allCount}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
{children}
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -2216,8 +2220,9 @@ function F4Pager({ page, totalPages, onPage }: {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode = false, onReverse, onView, onEdit, onDelete, onPermission, onUnbind, onFileError }: {
|
|
|
+function F4DocumentTable({ items, keyword, selectedId, onSelect, onDetail, bindingMode = false, onReverse, onView, onEdit, onDelete, onPermission, onUnbind, onFileError }: {
|
|
|
items: (DocumentSummary | AttachmentBindingSummary)[];
|
|
|
+ keyword?: string;
|
|
|
selectedId?: string | null;
|
|
|
onSelect?: (item: DocumentSummary) => void;
|
|
|
onDetail: (item: DocumentSummary, attachment: boolean) => void;
|
|
|
@@ -2236,7 +2241,7 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
|
|
|
<thead className="sticky top-0 z-10">
|
|
|
<tr style={{ background: N }}>
|
|
|
{["序号", "名称", "类型/分类", "密级", "概述与标签", bindingMode ? "挂载顺序" : "更新时间", "操作"].map((heading) => (
|
|
|
- <th key={heading} className="px-4 py-3 text-left text-sm font-black whitespace-nowrap" style={{ color: W }}>{heading}</th>
|
|
|
+ <th key={heading} className={`px-4 py-3 text-sm font-black whitespace-nowrap ${heading === "序号" ? "text-center" : "text-left"}`} style={{ color: W }}>{heading}</th>
|
|
|
))}
|
|
|
</tr>
|
|
|
</thead>
|
|
|
@@ -2248,9 +2253,10 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
|
|
|
const canEdit = item.allowedActions.includes(AllowedAction.EDIT);
|
|
|
const canDelete = item.allowedActions.includes(AllowedAction.DELETE);
|
|
|
const binding = bindingMode ? item as AttachmentBindingSummary : null;
|
|
|
+ const kw = (keyword ?? "").trim();
|
|
|
return (
|
|
|
+ <Fragment key={binding?.bindingId ?? item.id}>
|
|
|
<tr
|
|
|
- key={binding?.bindingId ?? item.id}
|
|
|
onClick={() => onSelect?.(item)}
|
|
|
className="border-b cursor-pointer"
|
|
|
style={{
|
|
|
@@ -2258,9 +2264,9 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
|
|
|
background: selectedId === item.id ? "rgba(49,145,235,0.12)" : index % 2 ? "rgba(40,118,193,0.025)" : W,
|
|
|
}}
|
|
|
>
|
|
|
- <td className="px-4 py-3 text-sm font-bold" style={{ color: N }}>{index + 1}</td>
|
|
|
+ <td className="px-4 py-3 text-sm font-bold text-center" style={{ color: N }}>{index + 1}</td>
|
|
|
<td className="px-4 py-3">
|
|
|
- <div className="text-sm font-black" style={{ color: N }}>{item.documentName}</div>
|
|
|
+ <div className="text-sm font-black" style={{ color: N }}>{kw ? <HighlightText text={item.documentName} keyword={kw} /> : item.documentName}</div>
|
|
|
{attachment && <div className="text-[11px] font-bold opacity-55" style={{ color: N }}>
|
|
|
{item.fileExtension ? item.fileExtension.toUpperCase() : "无扩展名"}
|
|
|
</div>}
|
|
|
@@ -2270,9 +2276,9 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
|
|
|
</td>
|
|
|
<td className="px-4 py-3"><LevelBadge level={dictionaries.label("securityLevels", item.securityLevel)} /></td>
|
|
|
<td className="px-4 py-3 max-w-[360px]">
|
|
|
- <div className="text-xs line-clamp-2" style={{ color: "rgba(26,82,153,.65)" }}>{item.summary || "暂无概述"}</div>
|
|
|
+ <div className="text-xs line-clamp-2" style={{ color: "rgba(26,82,153,.65)" }}>{kw ? <HighlightText text={item.summary || "暂无概述"} keyword={kw} /> : (item.summary || "暂无概述")}</div>
|
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
|
- {item.tags.map((tag) => <span key={tag} className="px-1.5 py-0.5 text-[10px] font-black" style={{ color: B, background: "rgba(49,145,235,.1)" }}>{tag}</span>)}
|
|
|
+ {item.tags.map((tag) => <span key={tag} className="px-1.5 py-0.5 text-[10px] font-black" style={{ color: B, background: "rgba(49,145,235,.1)" }}>{kw ? <HighlightText text={tag} keyword={kw} /> : tag}</span>)}
|
|
|
</div>
|
|
|
</td>
|
|
|
<td className="px-4 py-3 text-xs font-bold whitespace-nowrap" style={{ color: N }}>
|
|
|
@@ -2290,6 +2296,22 @@ function F4DocumentTable({ items, selectedId, onSelect, onDetail, bindingMode =
|
|
|
{attachment && onReverse && <button onClick={(event) => { event.stopPropagation(); onReverse(item); }} className="text-xs font-black" style={{ color: B }}>挂载主案({item.mountedPlanCount ?? 0})</button>}
|
|
|
</td>
|
|
|
</tr>
|
|
|
+ {item.contentSnippet && (
|
|
|
+ <tr style={{
|
|
|
+ borderColor: "rgba(40,118,193,0.1)",
|
|
|
+ background: selectedId === item.id ? "rgba(49,145,235,0.12)" : index % 2 ? "rgba(40,118,193,0.025)" : W,
|
|
|
+ }}>
|
|
|
+ <td className="px-4 py-3 text-center align-top" style={{ borderBottom: "1px solid rgba(40,118,193,0.1)" }}>
|
|
|
+ <span className="text-xs font-black opacity-50" style={{ color: N }}>正文</span>
|
|
|
+ </td>
|
|
|
+ <td colSpan={6} className="px-4 py-3" style={{ borderBottom: "1px solid rgba(40,118,193,0.1)" }}>
|
|
|
+ <div className="text-xs py-1" style={{ color: N }}>
|
|
|
+ {kw ? <HighlightText text={item.contentSnippet} keyword={kw} /> : item.contentSnippet}
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ )}
|
|
|
+ </Fragment>
|
|
|
);
|
|
|
})}
|
|
|
</tbody>
|
|
|
@@ -2457,6 +2479,7 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
const [updatedFrom, setUpdatedFrom] = useState("");
|
|
|
const [updatedTo, setUpdatedTo] = useState("");
|
|
|
const [categoryId, setCategoryId] = useState<string | null>(null);
|
|
|
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
|
const [allCategoryCount, setAllCategoryCount] = useState<number | null>(null);
|
|
|
const [page, setPage] = useState(1);
|
|
|
const [selected, setSelected] = useState<DocumentSummary | null>(null);
|
|
|
@@ -2483,12 +2506,14 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
categoryId: categoryId ?? undefined,
|
|
|
includeDescendants: categoryId ? true : undefined,
|
|
|
keyword: keyword.trim() || undefined,
|
|
|
+ tags: selectedTags.length > 0 ? selectedTags : undefined,
|
|
|
...mainRange.query,
|
|
|
sortBy: "documentName",
|
|
|
sortDirection: "asc",
|
|
|
page,
|
|
|
pageSize: 20,
|
|
|
}, !mainRange.error);
|
|
|
+ const tagsState = useDocumentTags();
|
|
|
const selectedMainId = selected?.documentType === DocumentType.MAIN ? selected.id : null;
|
|
|
const subPlans = useSubPlanList(selectedMainId, {
|
|
|
keyword: subKeyword.trim() || undefined,
|
|
|
@@ -2511,6 +2536,16 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
if (categoryId === null && documents.data) setAllCategoryCount(documents.data.total);
|
|
|
}, [categoryId, documents.data]);
|
|
|
|
|
|
+ const toggleTag = useCallback((tag: string) => {
|
|
|
+ setSelectedTags((current) =>
|
|
|
+ current.includes(tag)
|
|
|
+ ? current.filter((item) => item !== tag)
|
|
|
+ : [...current, tag],
|
|
|
+ );
|
|
|
+ setPage(1);
|
|
|
+ setSelected(null);
|
|
|
+ }, []);
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
setSubKeyword("");
|
|
|
setSubStatus("");
|
|
|
@@ -2526,7 +2561,17 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
|
|
|
return (
|
|
|
<PageFrame title="方案管理系统" current="main" navigationItems={navigationItems} onNav={onNav} onLogout={onLogout} logoutPending={logoutPending} currentUser={currentUser}>
|
|
|
- <LeftPanel allCount={allCategoryCount} selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPage(1); setSelected(null); setPreview(null); }} />
|
|
|
+ <LeftPanel allCount={allCategoryCount} selectedCategoryId={categoryId} onSelectCategory={(id) => { setCategoryId(id); setPage(1); setSelected(null); setPreview(null); }}>
|
|
|
+ <TagPanel
|
|
|
+ tags={tagsState.data}
|
|
|
+ selectedTags={selectedTags}
|
|
|
+ loading={tagsState.loading}
|
|
|
+ error={tagsState.error?.message ?? null}
|
|
|
+ onToggleTag={toggleTag}
|
|
|
+ onClear={() => { setSelectedTags([]); setPage(1); setSelected(null); }}
|
|
|
+ onRetry={tagsState.retry}
|
|
|
+ />
|
|
|
+ </LeftPanel>
|
|
|
<div className="flex-1 flex flex-col m-3 ml-0 min-h-0">
|
|
|
<DocumentSearchToolbar
|
|
|
ariaLabel="方案检索"
|
|
|
@@ -2542,12 +2587,12 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
/>
|
|
|
<div className="flex-1 overflow-auto min-h-0 mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
|
|
|
<F4State loading={documents.loading} error={documents.error} empty={documents.data?.items.length === 0} onRetry={documents.retry} />
|
|
|
- {documents.data && documents.data.items.length > 0 && <F4DocumentTable items={documents.data.items} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} />}
|
|
|
+ {documents.data && documents.data.items.length > 0 && <F4DocumentTable items={documents.data.items} keyword={keyword} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} />}
|
|
|
{documents.data && <F4Pager page={page} totalPages={documents.data.totalPages} onPage={(value) => { setPage(value); setSelected(null); }} />}
|
|
|
</div>
|
|
|
<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)" }}>
|
|
|
- {([{ id: "sub", label: "直属子方案" }, { id: "mounted", label: "已挂载共享附件" }] as const).map((item) => (
|
|
|
+ {([{ id: "sub", label: "子方案" }, { id: "mounted", label: "配套资料" }] as const).map((item) => (
|
|
|
<button key={item.id} onClick={() => setLowerTab(item.id)} className="px-6 py-2.5 text-sm font-black" style={{ color: lowerTab === item.id ? W : N, background: lowerTab === item.id ? N : W }}>{item.label}</button>
|
|
|
))}
|
|
|
<span className="ml-auto px-4 py-3 text-xs font-bold" style={{ color: N }}>{selected ? `当前:${selected.documentName}` : "请选择一个主案"}</span>
|
|
|
@@ -2557,12 +2602,12 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
|
|
|
<div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案查看关联数据</div>
|
|
|
) : lowerTab === "sub" ? (
|
|
|
<>
|
|
|
- <DocumentSearchToolbar ariaLabel="直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索方案名称、概述、标签或文档内容" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
|
|
|
- selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "直属子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
|
|
|
+ <DocumentSearchToolbar ariaLabel="子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索方案名称、概述、标签或文档内容" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
|
|
|
+ selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
|
|
|
onKeywordChange={(value) => { setSubKeyword(value); setSubPage(1); }} onUpdatedFromChange={(value) => { setSubUpdatedFrom(value); setSubPage(1); }} onUpdatedToChange={(value) => { setSubUpdatedTo(value); setSubPage(1); }}
|
|
|
onClear={() => { setSubKeyword(""); setSubStatus(""); setSubUpdatedFrom(""); setSubUpdatedTo(""); setSubPage(1); }} />
|
|
|
<F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} onRetry={subPlans.retry} />
|
|
|
- {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} /> : null}
|
|
|
+ {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} keyword={subKeyword} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onFileError={setFileError} /> : null}
|
|
|
{subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
|
|
|
</>
|
|
|
) : (
|
|
|
@@ -2669,13 +2714,13 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
onClear={() => { setKeyword(""); setUpdatedFrom(""); setUpdatedTo(""); setPlanPage(1); setSelected(null); }} />
|
|
|
<div className="flex-1 overflow-auto min-h-0 mb-2" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
|
|
|
<F4State loading={plans.loading} error={plans.error} empty={plans.data?.items.length === 0} onRetry={plans.retry} />
|
|
|
- {plans.data?.items.length ? <F4DocumentTable items={plans.data.items} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
|
|
|
+ {plans.data?.items.length ? <F4DocumentTable items={plans.data.items} keyword={keyword} selectedId={selected?.id} onSelect={(item) => { setSelected(item); setPreview(item); }} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onPermission={setPermission} onFileError={setFileError} /> : null}
|
|
|
{plans.data && <F4Pager page={planPage} totalPages={plans.data.totalPages} onPage={(value) => { setPlanPage(value); setSelected(null); }} />}
|
|
|
</div>
|
|
|
<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)" }}>
|
|
|
- <button onClick={() => setRelationTab("sub")} className="px-7 py-2.5 text-sm font-black" style={{ color: relationTab === "sub" ? W : N, background: relationTab === "sub" ? N : W }}>直属子方案</button>
|
|
|
- <button onClick={() => setRelationTab("mounted")} className="px-7 py-2.5 text-sm font-black" style={{ color: relationTab === "mounted" ? W : N, background: relationTab === "mounted" ? N : W }}>已挂载共享附件</button>
|
|
|
+ <button onClick={() => setRelationTab("sub")} className="px-7 py-2.5 text-sm font-black" style={{ color: relationTab === "sub" ? W : N, background: relationTab === "sub" ? N : W }}>子方案</button>
|
|
|
+ <button onClick={() => setRelationTab("mounted")} className="px-7 py-2.5 text-sm font-black" style={{ color: relationTab === "mounted" ? W : N, background: relationTab === "mounted" ? N : W }}>配套资料</button>
|
|
|
{selected?.documentType === DocumentType.MAIN && selected.allowedActions.includes(AllowedAction.BIND_ATTACHMENT) && <button onClick={() => setBindingPlan(selected)} className="ml-3 my-1.5 px-3 text-xs font-black text-white" style={{ background: B }}>挂载资料</button>}
|
|
|
{selected?.documentType === DocumentType.MAIN && selected.allowedActions.includes(AllowedAction.CONFIG_PERMISSION) && <button onClick={() => setPermission(selected)} className="my-1.5 ml-2 px-3 text-xs font-black border" style={{ color: N, borderColor: B }}>权限配置</button>}
|
|
|
<span className="ml-auto px-4 py-3 text-xs font-bold" style={{ color: N }}>{selected ? `当前主案:${selected.documentName}` : "请选择主案"}</span>
|
|
|
@@ -2683,13 +2728,13 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
|
|
|
<div className="flex-1 overflow-auto min-h-0">
|
|
|
{!selected ? <div className="p-12 text-center text-sm font-bold opacity-50" style={{ color: N }}>请选择主案</div> : relationTab === "sub" ? (
|
|
|
<>
|
|
|
- <DocumentSearchToolbar ariaLabel="后台直属子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索方案名称、概述、标签或文档内容" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
|
|
|
- selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "后台直属子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
|
|
|
+ <DocumentSearchToolbar ariaLabel="后台子方案检索" compact keyword={subKeyword} keywordPlaceholder="搜索方案名称、概述、标签或文档内容" updatedFrom={subUpdatedFrom} updatedTo={subUpdatedTo} error={subRange.error}
|
|
|
+ selectFilter={{ dictionary: "documentStatuses", value: subStatus, emptyLabel: "全部状态", ariaLabel: "后台子方案状态", onChange: (value) => { setSubStatus(value as DocumentStatusCode | ""); setSubPage(1); } }}
|
|
|
onKeywordChange={(value) => { setSubKeyword(value); setSubPage(1); }} onUpdatedFromChange={(value) => { setSubUpdatedFrom(value); setSubPage(1); }} onUpdatedToChange={(value) => { setSubUpdatedTo(value); setSubPage(1); }}
|
|
|
onClear={() => { setSubKeyword(""); setSubStatus(""); setSubUpdatedFrom(""); setSubUpdatedTo(""); setSubPage(1); }} />
|
|
|
<F4State loading={subPlans.loading} error={subPlans.error} empty={subPlans.data?.items.length === 0} onRetry={subPlans.retry} />
|
|
|
<div className="px-4 py-2 text-xs font-bold" style={{ color: N }}>权限继承自主案:{selected.documentName}</div>
|
|
|
- {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}
|
|
|
+ {subPlans.data?.items.length ? <F4DocumentTable items={subPlans.data.items} keyword={subKeyword} onSelect={setPreview} onDetail={(item, attachment) => setDetail({ item, attachment })} onView={setFullView} onEdit={setEdit} onDelete={setRemove} onFileError={setFileError} /> : null}
|
|
|
{subPlans.data && <F4Pager page={subPage} totalPages={subPlans.data.totalPages} onPage={setSubPage} />}
|
|
|
</>
|
|
|
) : (
|