Commit a455bb75 authored by David Yang's avatar David Yang

feat: 应用切换

parent a3dfaf51
No preview for this file type
...@@ -14,6 +14,8 @@ const ROUTE_PATHS = { ...@@ -14,6 +14,8 @@ const ROUTE_PATHS = {
export default defineConfig({ export default defineConfig({
layout: false, layout: false,
fastRefresh: true, fastRefresh: true,
base: '/audit_material_manager/',
publicPath: '/audit_material_manager/',
alias: { alias: {
react: resolvePackageRoot('react'), react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'), 'react-dom': resolvePackageRoot('react-dom'),
......
{
"name": "audit_material_manager",
"version": "1.0.2",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
No preview for this file type
import { getApplications } from '@/services/DataService';
import * as Headless from '@headlessui/react';
import {
ArrowTopRightOnSquareIcon,
ExclamationCircleIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
return {
name,
title:
application?.title ||
fallback?.title ||
application?.name ||
fallback?.name ||
'未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
};
}
function formatAccessLabel(access) {
if (access === 'public') {
return '公开';
}
if (access === 'protected') {
return '受限';
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(
application.name,
normalizeApplication(application, application),
);
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
function ApplicationCard({ application }) {
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
return (
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
className={clsx(
'group relative min-h-[84px] overflow-hidden rounded-2xl border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.06)] transition duration-200 ease-out hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-[0_16px_32px_rgba(244,63,94,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
current &&
'border-rose-300 bg-[linear-gradient(180deg,rgba(255,241,242,0.95),rgba(255,255,255,0.96))]',
)}
>
<div className="absolute inset-x-0 top-0 h-14 bg-[radial-gradient(circle_at_top_right,rgba(251,113,133,0.14),transparent_50%)] opacity-0 transition duration-200 group-hover:opacity-100" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
{application.logo ? (
<img
src={application.logo}
alt={application.title}
className="size-9 rounded-xl border border-white/80 bg-white object-cover shadow-sm"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,#b42318,#f43f5e)] text-sm font-semibold text-white shadow-[0_8px_18px_rgba(180,35,24,0.22)]">
{application.title.slice(0, 1)}
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-950">
{application.title}
</div>
<div className="mt-0.5 truncate text-[11px] leading-4 text-slate-500">
{application.description}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{current ? (
<span className="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] font-medium text-rose-700">
当前
</span>
) : null}
<span className="rounded-full bg-slate-100 p-1.5 text-slate-500 transition group-hover:bg-rose-50 group-hover:text-rose-600">
<ArrowTopRightOnSquareIcon className="size-3.5" />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className="relative mt-2 flex flex-wrap gap-1.5">
{accessLabel ? (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{accessLabel}
</span>
) : null}
{firstRole ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">
{firstRole}
</span>
) : null}
{firstGroup ? (
<span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700">
{firstGroup}
</span>
) : null}
</div>
) : null}
</a>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[84px] rounded-2xl border border-zinc-200 bg-white/90 p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.05)]"
>
<div className="animate-pulse">
<div className="flex items-start gap-3">
<div className="size-9 rounded-xl bg-zinc-200" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-zinc-200" />
<div className="h-3 w-full rounded-full bg-zinc-100" />
</div>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-12 rounded-full bg-zinc-100" />
<div className="h-5 w-14 rounded-full bg-zinc-100" />
</div>
</div>
</div>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
siteInfo,
}) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, siteInfo?.group);
}, [applications, siteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Headless.Dialog
open={open}
onClose={onClose}
className="relative z-[180]"
>
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-[rgba(15,23,42,0.48)] backdrop-blur-sm transition duration-300 ease-out data-closed:opacity-0"
/>
<div className="fixed inset-0 overflow-y-auto p-3 sm:p-4 lg:p-5">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
transition
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-[92rem] flex-col overflow-hidden rounded-[28px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(248,250,252,0.94))] p-4 shadow-[0_30px_80px_rgba(15,23,42,0.22)] ring-1 ring-black/5 transition duration-300 ease-out data-closed:translate-y-4 data-closed:scale-[0.97] data-closed:opacity-0 sm:p-5"
>
<div className="flex shrink-0 flex-col gap-3 border-b border-zinc-200/80 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-600">
<SparklesIcon className="size-3.5" />
Applications
</div>
<Headless.DialogTitle className="mt-2 text-xl font-semibold tracking-tight text-slate-950 sm:text-2xl">
应用切换
</Headless.DialogTitle>
<p className="mt-1 max-w-2xl text-xs leading-5 text-slate-500">
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-rose-100 bg-rose-50/70 px-3 py-2 text-right">
<div className="text-[11px] font-medium tracking-wide text-rose-500">
可见应用
</div>
<div className="mt-0.5 text-xl font-semibold tracking-tight text-slate-950">
{applications.length}
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 items-center justify-center rounded-xl border border-zinc-200 bg-white px-3.5 text-sm font-medium text-slate-600 transition hover:border-zinc-300 hover:text-slate-900"
>
关闭
</button>
</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-gutter:stable]">
{status === 'error' && errorMessage ? (
<div className="mb-4 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<ExclamationCircleIcon className="mt-0.5 size-5 shrink-0 text-amber-500" />
<div>{errorMessage}</div>
</div>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="space-y-4">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-2 flex items-center gap-3">
<h3 className="text-sm font-semibold text-slate-900">
{section.label}
</h3>
<span className="h-px flex-1 bg-zinc-200/80" />
<span className="text-xs font-medium text-slate-400">
{section.applications.length}
</span>
</div>
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-[28px] border border-dashed border-zinc-200 bg-zinc-50/80 px-6 text-center">
<div className="text-lg font-semibold text-slate-900">
暂无可切换应用
</div>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</p>
</div>
)}
</div>
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
);
}
import xyLogo from '@/assets/xy-logo.png'; import xyLogo from '@/assets/xy-logo.png';
import xyLogoSmall from '@/assets/xy-logo_small.png'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { import {
ArchiveBoxIcon, ArchiveBoxIcon,
Bars3Icon, Bars3Icon,
...@@ -18,7 +20,7 @@ const menuGroups = [ ...@@ -18,7 +20,7 @@ const menuGroups = [
title: '统一材料工作台', title: '统一材料工作台',
items: [ items: [
{ {
name: '材料总览', name: '工作台',
path: '/resource/stat', path: '/resource/stat',
icon: Squares2X2Icon, icon: Squares2X2Icon,
}, },
...@@ -174,10 +176,12 @@ function MobileSidebar({ open, close, children }) { ...@@ -174,10 +176,12 @@ function MobileSidebar({ open, close, children }) {
export default function ProjectManagerLayout() { export default function ProjectManagerLayout() {
const location = useLocation(); const location = useLocation();
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const userInfo = useContext(UserContext);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
...@@ -219,6 +223,21 @@ export default function ProjectManagerLayout() { ...@@ -219,6 +223,21 @@ export default function ProjectManagerLayout() {
); );
}, [location.pathname]); }, [location.pathname]);
const applicationSwitcherButton = (
<button
type="button"
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
title="应用切换"
className={clsx(
'inline-flex items-center justify-center rounded-[20px] border border-[#f97316]/70 bg-[linear-gradient(180deg,rgba(39,39,42,0.92),rgba(24,24,27,0.96))] text-[#fb923c] shadow-[0_16px_30px_rgba(249,115,22,0.16)] transition duration-200 hover:-translate-y-0.5 hover:border-[#fb923c] hover:text-[#fdba74] hover:shadow-[0_18px_36px_rgba(249,115,22,0.26)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/50',
sidebarCollapsed ? 'size-11' : 'size-14',
)}
>
<Squares2X2Icon className={clsx(sidebarCollapsed ? 'size-5' : 'size-7')} />
</button>
);
return ( return (
<div className="flex h-screen overflow-hidden bg-zinc-50"> <div className="flex h-screen overflow-hidden bg-zinc-50">
<aside <aside
...@@ -229,22 +248,34 @@ export default function ProjectManagerLayout() { ...@@ -229,22 +248,34 @@ export default function ProjectManagerLayout() {
> >
<div <div
className={clsx( className={clsx(
'mb-7 flex items-center', 'mb-7',
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1', sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)} )}
> >
{sidebarCollapsed ? ( {sidebarCollapsed ? (
<img <>
src={xyLogoSmall} <img
alt="XY Logo" src={xyLogoSmall}
className="h-9 w-auto max-w-[52px] shrink-0 object-contain" alt="XY Logo"
/> className="h-8 w-auto max-w-8 shrink-0 object-contain"
) : null} />
{!sidebarCollapsed ? ( {applicationSwitcherButton}
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> </>
{APP_NAME} ) : (
</h1> <>
) : null} <div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME}
</h1>
<div className="mt-1 text-xs text-zinc-400">
统一审计材料上传与归档中心
</div>
</div>
{applicationSwitcherButton}
</>
)}
</div> </div>
<nav className="flex-1 space-y-5 overflow-y-auto"> <nav className="flex-1 space-y-5 overflow-y-auto">
...@@ -270,6 +301,17 @@ export default function ProjectManagerLayout() { ...@@ -270,6 +301,17 @@ export default function ProjectManagerLayout() {
</div> </div>
))} ))}
</nav> </nav>
{!sidebarCollapsed ? (
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-300">
<div className="font-medium text-white/90">
{userInfo?.display_name || userInfo?.username || '当前用户'}
</div>
<div className="mt-1 text-xs text-zinc-500">
当前正在查看审计材料中心
</div>
</div>
) : null}
</aside> </aside>
<div className="relative z-30 hidden lg:flex"> <div className="relative z-30 hidden lg:flex">
...@@ -341,12 +383,26 @@ export default function ProjectManagerLayout() { ...@@ -341,12 +383,26 @@ export default function ProjectManagerLayout() {
<Bars3Icon className="size-5" /> <Bars3Icon className="size-5" />
</NavbarItem> </NavbarItem>
<div className="text-sm text-zinc-500">{currentGroupTitle}</div> <div className="text-sm text-zinc-500">{currentGroupTitle}</div>
<NavbarItem
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
className="border border-rose-200 bg-white text-rose-500 shadow-sm hover:bg-rose-50 hover:text-rose-600"
>
<Squares2X2Icon className="size-5" />
</NavbarItem>
</div> </div>
<div className="flex-1 overflow-y-auto rounded-[32px] border border-white/70 bg-white/75 p-4 shadow-[0_24px_80px_rgba(15,23,42,0.06)] backdrop-blur sm:p-6"> <div className="flex-1 overflow-y-auto rounded-[32px] border border-white/70 bg-white/75 p-4 shadow-[0_24px_80px_rgba(15,23,42,0.06)] backdrop-blur sm:p-6">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div> </div>
); );
} }
...@@ -21,13 +21,14 @@ ...@@ -21,13 +21,14 @@
建议保留现有两级菜单框架,但替换业务语义: 建议保留现有两级菜单框架,但替换业务语义:
- 左侧菜单:`材料总览` - 左侧菜单:`工作台`
- 左侧菜单:`材料归档` - 左侧菜单:`材料归档`
- 左侧菜单顶部:`应用切换` 入口,打开应用切换弹框
推荐职责划分: 推荐职责划分:
- `材料总览` - `工作台`
- 展示材料总数、分类分布、来源应用分布、最近上传 - 展示材料总数、分类分布、来源应用/来源系统分布、最近上传
- 提供全局检索入口 - 提供全局检索入口
- `材料归档` - `材料归档`
- 上传材料 - 上传材料
...@@ -35,6 +36,12 @@ ...@@ -35,6 +36,12 @@
- 查看材料列表与详情 - 查看材料列表与详情
- 支持“全部文件”列表视图与按分类切换浏览 - 支持“全部文件”列表视图与按分类切换浏览
- 材料详情使用弹框查看与编辑 - 材料详情使用弹框查看与编辑
- `应用切换`
- 桌面端入口位于左侧菜单栏顶部;菜单折叠时保留九宫格图标按钮
- 移动端入口位于顶部导航栏右侧
- 打开弹框后调用 `/application/info` 获取当前账号可访问应用,并与登录用户信息中的 `applications` 合并展示
- 应用列表加载失败时,回退展示当前账号上的应用信息,并显示失败提示
- 点击应用卡片后进入对应应用工作区
--- ---
...@@ -49,6 +56,7 @@ ...@@ -49,6 +56,7 @@
- `material_title`:材料标题 - `material_title`:材料标题
- `file_asset`:文件对象 - `file_asset`:文件对象
- `source_app`:来源应用,可选 - `source_app`:来源应用,可选
- `source_system`:来源系统,可选
- `archive_category`:分类,可选 - `archive_category`:分类,可选
- `uploader` / `uploaded_at`:上传人、上传时间 - `uploader` / `uploaded_at`:上传人、上传时间
- `remark`:备注,可选 - `remark`:备注,可选
...@@ -67,47 +75,60 @@ ...@@ -67,47 +75,60 @@
2. 选择本地文件;若选择的是 `ZIP` 压缩包,可进一步选择上传模式: 2. 选择本地文件;若选择的是 `ZIP` 压缩包,可进一步选择上传模式:
- `整包上传`:将压缩包本身作为 1 份材料处理 - `整包上传`:将压缩包本身作为 1 份材料处理
- `解压后逐文件上传`:按压缩包内文件逐个建档 - `解压后逐文件上传`:按压缩包内文件逐个建档
3. 用户补充标题、标签、分类、提供部门、提供时间、是否涉密、是否审计部材料等信息 3. 用户补充标题、来源应用、来源系统、标签、分类、提供部门、提供时间、是否涉密、是否审计部材料等信息
4. 先调用上传接口上传二进制文件 4. 先调用上传接口上传二进制文件
5. 上传成功后,得到 `{name, type, size, url}` 文件对象 5. 上传成功后,得到 `{name, type, size, url}` 文件对象
6. 再创建 `am_material` 记录: 6. 再创建 `am_material` 记录:
- 普通文件或 `整包上传`:创建 1 条记录 - 普通文件或 `整包上传`:创建 1 条记录
- `解压后逐文件上传`:压缩包内有多少个文件,就创建多少条记录 - `解压后逐文件上传`:压缩包内有多少个文件,就创建多少条记录
7. 当使用 `解压后逐文件上传` 时,上传表单中的来源应用、分类、标签、提供信息、合规属性等字段作为每个文件的通用属性一并写入 7. 当使用 `解压后逐文件上传` 时,上传表单中的来源应用、来源系统、分类、标签、提供信息、合规属性等字段作为每个文件的通用属性一并写入
8. 材料进入统一材料池,可被其他审计应用统一查看 8. 材料进入统一材料池,可被其他审计应用统一查看
### 4.2 在其他业务应用上传材料 ### 4.2 在其他业务应用上传材料
1. 用户在 `审计项目管理` 或 `问题整改` 等业务页面上传附件 1. 用户在 `审计项目管理` 或 `问题整改` 等业务页面上传附件
2. 前端或中台先上传文件,再创建 `am_material` 2. 前端或中台先上传文件,再创建 `am_material`
3. 如有需要,可在 `source_app` 中标记材料来源应用 3. 如有需要,可在 `source_app` 中标记材料来源应用,在 `source_system` 中标记材料来源系统
4. 材料中心自动能查到这份材料,并能按来源应用统一管理 4. 材料中心自动能查到这份材料,并能按来源应用或来源系统统一管理
### 4.3 在材料中心统一查看与管理 ### 4.3 在材料中心统一查看与管理
1. 用户进入 `材料归档` 1. 用户进入 `材料归档`
2. 按关键词、标签、来源应用筛选材料 2. 在“搜索材料”区域按字段手填筛选条件,支持模糊搜索:
3. 页面提供“全部文件”与“分类切换”两种浏览方式 - 材料标题:匹配 `metadata.material_title`
- 文件名:匹配 `metadata.file_asset.name`
- 材料分类:匹配 `metadata.archive_category`
- 标签分组:匹配 `metadata.tag`
- 来源应用:匹配 `metadata.source_app`
- 来源系统:匹配 `metadata.source_system`
- 提供部门:匹配 `metadata.provide_department`
- 备注:匹配 `metadata.remark`
- 是否涉密:匹配 `metadata.is_confidential`
- 是否审计部材料:匹配 `metadata.is_audit_dept_material`
3. 页面提供“全部文件”与“分类快捷标签”两种浏览方式
4. 列表默认每页 20 条,支持切换为 50 或 100 条 4. 列表默认每页 20 条,支持切换为 50 或 100 条
5. 用户通过上一页 / 下一页进行分页浏览 5. 用户通过上一页 / 下一页进行分页浏览
6. 在“全部文件”视图下,直接以列表展示当前筛选条件下的全部材料 6. 在“全部文件”视图下,直接以列表展示当前筛选条件下的全部材料
7. 切换到某个分类标签后,只查看该分类下的材料 7. 切换到某个分类标签后,系统将分类名称回填到“材料分类”搜索字段,并重新从第一页加载模糊匹配结果
8. `未分类` 仅表示 `metadata.archive_category` 为空、空字符串或缺失,不作为真实分类值写回查询条件 8. `未分类` 仅表示 `metadata.archive_category` 为空、空字符串或缺失,不作为真实分类值写回查询条件
9. 列表支持当前页批量勾选,并提供“全选当前页”能力 9. 列表支持当前页批量勾选,并提供“全选当前页”能力
10. 勾选后可执行以下批量操作: 10. 勾选后可执行以下批量操作:
- 批量修改:按字段选择是否覆盖来源应用、分类、提供部门、提供时间、是否涉密、是否审计部材料 - 批量下载/导出:勾选 1 份材料时直接下载原文件;勾选多份材料时,前端读取各材料 `metadata.file_asset.url` 并打包为 `ZIP` 压缩包下载
- 批量修改:按字段选择是否覆盖来源应用、来源系统、分类、提供部门、提供时间、是否涉密、是否审计部材料
- 批量打标签:统一覆盖 `metadata.tag` - 批量打标签:统一覆盖 `metadata.tag`
- 批量删除:对已勾选材料执行软删除 - 批量删除:对已勾选材料执行软删除
11. 点击某份材料后,以弹框方式打开详情 11. 点击某份材料后,以弹框方式打开详情
12. 弹框内查看: 12. 弹框默认进入查看态,字段不可直接编辑,可查看:
- 文件预览或下载入口 - 文件信息与打开或下载入口
- 标签与分类 - 标签与分类
- 来源信息 - 来源信息
- 提供部门、提供时间 - 提供部门、提供时间
- 涉密标记、审计部材料标记 - 涉密标记、审计部材料标记
13. 管理员或有权限用户可在弹框中修改分类、标签 13. 用户点击“编辑”后进入编辑态,可修改材料标题、来源应用、来源系统、分类、标签、提供信息与合规属性
14. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框 14. 编辑态下点击“取消编辑”放弃未保存修改并恢复当前材料原值
15. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排 15. 编辑态下点击“保存修改”后调用材料更新接口,保存成功后生效并回到查看态
16. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框
17. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排
### 4.4 删除或归档材料 ### 4.4 删除或归档材料
...@@ -131,36 +152,41 @@ ...@@ -131,36 +152,41 @@
- 直接查询 `am_material` - 直接查询 `am_material`
- 需要按来源区分时: - 需要按来源区分时:
- 使用 `metadata.source_app` 过滤 - 使用 `metadata.source_app` 过滤
- 使用 `metadata.source_system` 过滤
### 5.3 材料中心如何看到其他应用上传的材料 ### 5.3 材料中心如何看到其他应用上传的材料
- 只要其他应用上传时同步创建 `am_material` - 只要其他应用上传时同步创建 `am_material`
- 并补充 `source_app` - 并补充 `source_app` 与 `source_system`
- 材料中心即可统一查询、展示与管理 - 材料中心即可统一查询、展示与管理
--- ---
## 6. 页面与交互建议 ## 6. 页面与交互建议
### 6.1 材料总览 ### 6.1 工作台
建议至少展示: 建议至少展示:
- 材料总数 - 材料总数
- 本周新增材料数 - 本周新增材料数
- 来源应用分布 - 来源应用分布
- 来源系统分布
- 分类分布 - 分类分布
- 提供信息完整度 - 提供信息完整度
- 涉密材料 / 审计部材料概览 - 涉密材料 / 审计部材料概览
- 最近上传材料 - 最近上传材料
- 待完善材料提醒
当前工作台推荐采用“图表 + 风险清单”布局: 当前工作台推荐采用“图表 + 最近上传列表”布局:
- 来源应用分布使用柱状图,便于快速判断材料是否已从各业务应用顺利汇入 - 来源应用分布使用柱状图,便于快速判断材料是否已从各业务应用顺利汇入
- 来源系统分布展示各系统汇入数量,便于判断跨系统同步是否完整
- 分类分布使用环形图,便于观察常用分类与未分类占比 - 分类分布使用环形图,便于观察常用分类与未分类占比
- 提供与合规信息使用双层环图或类似图形,突出补录完成度、涉密材料、审计部材料情况 - 提供与合规信息拆分为补录进度、合规标记、主要提供部门三块,避免把提供完整度、涉密状态和审计部材料状态集中在同一个图表中
- 最近上传与待完善材料以列表方式承接,方便后续直接进入归档页处理 - 补录进度展示提供信息完整率、已补齐数量、缺少提供部门数量、缺少提供时间数量,并提示待补录材料数
- 合规标记使用独立进度条分别展示涉密材料、审计部材料和其它材料占比,便于逐项判断风险密度
- 主要提供部门使用排行进度条展示已登记部门分布,方便识别材料提供来源是否集中
- 最近上传材料以列表方式承接,方便快速发现异常命名、来源遗漏或归档不完整的情况
### 6.2 材料归档 ### 6.2 材料归档
...@@ -169,9 +195,11 @@ ...@@ -169,9 +195,11 @@
- 上传按钮 - 上传按钮
- 标签筛选 - 标签筛选
- 来源应用筛选 - 来源应用筛选
- 来源系统筛选
- 分类切换标签页 - 分类切换标签页
- 材料列表表格 - 材料列表表格
- 当前页批量勾选工具条 - 当前页批量勾选工具条
- 批量下载/导出按钮
- 材料详情弹框 - 材料详情弹框
- 批量修改弹框 - 批量修改弹框
- 批量打标签弹框 - 批量打标签弹框
...@@ -180,7 +208,18 @@ ...@@ -180,7 +208,18 @@
- 提供信息区 - 提供信息区
- 合规属性区 - 合规属性区
### 6.3 材料详情 ### 6.3 应用切换弹框
建议至少包含:
- 菜单栏九宫格应用切换入口
- 当前账号可见应用数量
- 按站点配置分组展示应用;没有分组时展示“全部应用”
- 当前应用标记
- 接口加载失败提示与账号应用信息兜底
- 无可切换应用时的空状态
### 6.4 材料详情
建议展示: 建议展示:
...@@ -189,10 +228,13 @@ ...@@ -189,10 +228,13 @@
- 标签 - 标签
- 分类 - 分类
- 来源应用 - 来源应用
- 来源系统
- 提供部门 / 提供时间 - 提供部门 / 提供时间
- 是否涉密 / 是否审计部材料 - 是否涉密 / 是否审计部材料
- 上传人 / 上传时间 - 上传人 / 上传时间
- 下载或预览入口 - 打开或下载入口
- 默认只读查看,点击“编辑”后进入编辑态
- 编辑态支持“取消编辑”恢复当前材料原值,或“保存修改”提交后回到查看态
--- ---
...@@ -214,13 +256,29 @@ ...@@ -214,13 +256,29 @@
- `POST /record/query` - `POST /record/query`
- `subject = am_material` - `subject = am_material`
- 字段搜索采用展开式表单,不再使用聚合关键词搜索
- 可使用以下过滤条件: - 可使用以下过滤条件:
- `metadata.material_title`
- `metadata.file_asset.name`
- `metadata.source_app` - `metadata.source_app`
- `metadata.source_system`
- `metadata.archive_category` - `metadata.archive_category`
- `metadata.tag` - `metadata.tag`
- `metadata.provide_department`
- `metadata.remark`
- `metadata.is_confidential`
- `metadata.is_audit_dept_material`
- 文本字段按 `$regex` + `$options: i` 组织为模糊匹配
- 布尔字段采用“全部 / 是 / 否”三态筛选,选择“全部”时不进入查询条件
- 多个搜索字段同时填写时按 AND 关系组合
### 7.3 批量管理材料 ### 7.3 批量管理材料
- 批量下载/导出:
- 前端基于当前页勾选结果读取材料记录中的 `metadata.file_asset`
- 勾选 1 个文件时,使用 `metadata.file_asset.url` 直接触发浏览器下载
- 勾选多个文件时,前端按 `metadata.file_asset.name` 拉取文件内容并生成 ZIP 压缩包
- 若部分材料缺少 `metadata.file_asset.url` 或文件地址不可访问,则跳过该材料并在操作结果中提示跳过数量
- 批量修改: - 批量修改:
- 前端先基于当前页勾选结果拿到材料记录 - 前端先基于当前页勾选结果拿到材料记录
- 再按勾选字段逐条调用 `PUT /record/:record` - 再按勾选字段逐条调用 `PUT /record/:record`
...@@ -240,7 +298,7 @@ ...@@ -240,7 +298,7 @@
- 当材料数量超过 1000 份时,不应一次性全量拉取 - 当材料数量超过 1000 份时,不应一次性全量拉取
- 归档页首屏只加载一页数据,默认每页 20 条,可切换为 50 或 100 条 - 归档页首屏只加载一页数据,默认每页 20 条,可切换为 50 或 100 条
- 用户通过分页按钮切换页码,不再使用滚动加载 - 用户通过分页按钮切换页码,不再使用滚动加载
- 当用户切换分类、关键词、来源或页大小时,按当前条件重新从第一页加载 - 当用户调整任一搜索字段、点击分类快捷标签或切换页大小时,按当前条件重新从第一页加载
- “全部文件”视图按页展示结果 - “全部文件”视图按页展示结果
- 总数统计通过 `count` 接口单独获取,不依赖前端已加载条数推断 - 总数统计通过 `count` 接口单独获取,不依赖前端已加载条数推断
...@@ -253,6 +311,7 @@ ...@@ -253,6 +311,7 @@
- ZIP 拆分上传若中途失败,需要明确提示已成功创建的记录条数以及失败文件名,便于用户补传 - ZIP 拆分上传若中途失败,需要明确提示已成功创建的记录条数以及失败文件名,便于用户补传
- 标签允许自由输入,但需控制重复标签与空标签 - 标签允许自由输入,但需控制重复标签与空标签
- 分类和标签用于支持用户自定义分组 - 分类和标签用于支持用户自定义分组
- 批量下载/导出依赖 `metadata.file_asset.url` 可被当前浏览器访问;若跨域或文件失效导致拉取失败,多文件 ZIP 中会跳过失败项并提示用户
- 批量修改仅允许覆盖用户显式勾选的字段,未勾选字段必须保留原值,避免误覆盖 - 批量修改仅允许覆盖用户显式勾选的字段,未勾选字段必须保留原值,避免误覆盖
- 批量删除前必须二次确认,默认仍按软删除处理 - 批量删除前必须二次确认,默认仍按软删除处理
- 当前版本的批量勾选范围限定在“当前筛选结果的当前页”,切换分页后需要重新确认勾选结果 - 当前版本的批量勾选范围限定在“当前筛选结果的当前页”,切换分页后需要重新确认勾选结果
......
...@@ -24,6 +24,14 @@ const SOURCE_APP_LABEL_MAP = { ...@@ -24,6 +24,14 @@ const SOURCE_APP_LABEL_MAP = {
other_audit_app: '其它审计应用', other_audit_app: '其它审计应用',
}; };
const SOURCE_SYSTEM_LABEL_MAP = {
audit_material_manage: '审计材料管理系统',
project_manager_xy: '审计项目管理系统',
fund_audit: '数智报告平台',
aml_tables: '反洗钱数据表',
middle_platform: '中台',
};
export const SOURCE_APP_SUGGESTIONS = [ export const SOURCE_APP_SUGGESTIONS = [
{ value: 'audit_material_center', label: '材料中心' }, { value: 'audit_material_center', label: '材料中心' },
{ value: 'project_manager_xy', label: '审计项目管理' }, { value: 'project_manager_xy', label: '审计项目管理' },
...@@ -32,7 +40,70 @@ export const SOURCE_APP_SUGGESTIONS = [ ...@@ -32,7 +40,70 @@ export const SOURCE_APP_SUGGESTIONS = [
{ value: '中台', label: '中台' }, { value: '中台', label: '中台' },
]; ];
export const SOURCE_SYSTEM_SUGGESTIONS = [
{ value: 'audit_material_manage', label: '审计材料管理系统' },
{ value: 'project_manager_xy', label: '审计项目管理系统' },
{ value: 'fund_audit', label: '数智报告平台' },
{ value: 'aml_tables', label: '反洗钱数据表' },
{ value: 'middle_platform', label: '中台' },
];
const ARCHIVE_IGNORE_PATTERNS = [/^__MACOSX\//, /\/\.DS_Store$/i, /^\.DS_Store$/i]; const ARCHIVE_IGNORE_PATTERNS = [/^__MACOSX\//, /\/\.DS_Store$/i, /^\.DS_Store$/i];
const MATERIAL_EXPORT_ZIP_NAME_PREFIX = '审计材料批量导出';
const MATERIAL_SEARCH_FIELDS = [
{
filterKey: 'materialTitle',
recordPath: 'metadata.material_title',
valueGetter: (item) => item.materialTitle,
},
{
filterKey: 'fileName',
recordPath: 'metadata.file_asset.name',
valueGetter: (item) => item.fileName,
},
{
filterKey: 'sourceApp',
recordPath: 'metadata.source_app',
valueGetter: (item) => `${item.sourceApp} ${item.sourceAppLabel}`,
},
{
filterKey: 'sourceSystem',
recordPath: 'metadata.source_system',
valueGetter: (item) => `${item.sourceSystem} ${item.sourceSystemLabel}`,
},
{
filterKey: 'archiveCategory',
recordPath: 'metadata.archive_category',
valueGetter: (item) => item.rawMetadata?.archive_category || item.archiveCategory,
},
{
filterKey: 'tag',
recordPath: 'metadata.tag',
valueGetter: (item) => item.tag,
},
{
filterKey: 'provideDepartment',
recordPath: 'metadata.provide_department',
valueGetter: (item) => item.provideDepartment,
},
{
filterKey: 'remark',
recordPath: 'metadata.remark',
valueGetter: (item) => item.remark,
},
];
const MATERIAL_BOOLEAN_SEARCH_FIELDS = [
{
filterKey: 'isConfidential',
recordPath: 'metadata.is_confidential',
valueGetter: (item) => item.isConfidential,
},
{
filterKey: 'isAuditDeptMaterial',
recordPath: 'metadata.is_audit_dept_material',
valueGetter: (item) => item.isAuditDeptMaterial,
},
];
const DEMO_MATERIALS = [ const DEMO_MATERIALS = [
{ {
...@@ -51,6 +122,7 @@ const DEMO_MATERIALS = [ ...@@ -51,6 +122,7 @@ const DEMO_MATERIALS = [
url: 'https://example.com/materials/demo-1.pdf', url: 'https://example.com/materials/demo-1.pdf',
}, },
source_app: 'project_manager_xy', source_app: 'project_manager_xy',
source_system: 'project_manager_xy',
archive_category: '审计证据', archive_category: '审计证据',
uploader: { uploader: {
_id: '69b12a47e92b1de77c94a9f5', _id: '69b12a47e92b1de77c94a9f5',
...@@ -76,6 +148,7 @@ const DEMO_MATERIALS = [ ...@@ -76,6 +148,7 @@ const DEMO_MATERIALS = [
url: 'https://example.com/materials/demo-2.docx', url: 'https://example.com/materials/demo-2.docx',
}, },
source_app: 'audit_material_center', source_app: 'audit_material_center',
source_system: 'audit_material_manage',
archive_category: '项目资料', archive_category: '项目资料',
uploader: { uploader: {
_id: '69b12a47e92b1de77c94a9f5', _id: '69b12a47e92b1de77c94a9f5',
...@@ -101,6 +174,7 @@ const DEMO_MATERIALS = [ ...@@ -101,6 +174,7 @@ const DEMO_MATERIALS = [
url: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=900&q=80', url: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=900&q=80',
}, },
source_app: 'issue_rectification', source_app: 'issue_rectification',
source_system: 'project_manager_xy',
archive_category: '整改资料', archive_category: '整改资料',
uploader: { uploader: {
_id: '69b12a47e92b1de77c94a9f6', _id: '69b12a47e92b1de77c94a9f6',
...@@ -126,6 +200,7 @@ const DEMO_MATERIALS = [ ...@@ -126,6 +200,7 @@ const DEMO_MATERIALS = [
url: 'https://example.com/materials/demo-4.pdf', url: 'https://example.com/materials/demo-4.pdf',
}, },
source_app: 'audit_material_center', source_app: 'audit_material_center',
source_system: 'audit_material_manage',
archive_category: '项目资料', archive_category: '项目资料',
uploader: { uploader: {
_id: '69b12a47e92b1de77c94a9f7', _id: '69b12a47e92b1de77c94a9f7',
...@@ -151,6 +226,7 @@ const DEMO_MATERIALS = [ ...@@ -151,6 +226,7 @@ const DEMO_MATERIALS = [
url: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=900&q=80', url: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=900&q=80',
}, },
source_app: 'project_manager_xy', source_app: 'project_manager_xy',
source_system: 'project_manager_xy',
archive_category: '审计证据', archive_category: '审计证据',
uploader: { uploader: {
_id: '69b12a47e92b1de77c94a9f8', _id: '69b12a47e92b1de77c94a9f8',
...@@ -265,6 +341,14 @@ export function getSourceAppLabel(sourceApp = '') { ...@@ -265,6 +341,14 @@ export function getSourceAppLabel(sourceApp = '') {
return SOURCE_APP_LABEL_MAP[sourceApp] || sourceApp; return SOURCE_APP_LABEL_MAP[sourceApp] || sourceApp;
} }
export function getSourceSystemLabel(sourceSystem = '') {
if (!sourceSystem) {
return '未标记系统';
}
return SOURCE_SYSTEM_LABEL_MAP[sourceSystem] || sourceSystem;
}
export function normalizeMaterialRecord(record = {}) { export function normalizeMaterialRecord(record = {}) {
const metadata = record?.metadata || {}; const metadata = record?.metadata || {};
const fileAsset = metadata.file_asset || {}; const fileAsset = metadata.file_asset || {};
...@@ -272,6 +356,7 @@ export function normalizeMaterialRecord(record = {}) { ...@@ -272,6 +356,7 @@ export function normalizeMaterialRecord(record = {}) {
const materialTitle = metadata.material_title || record.title || '未命名材料'; const materialTitle = metadata.material_title || record.title || '未命名材料';
const uploadedAt = metadata.uploaded_at || record.created_at || ''; const uploadedAt = metadata.uploaded_at || record.created_at || '';
const sourceApp = metadata.source_app || ''; const sourceApp = metadata.source_app || '';
const sourceSystem = metadata.source_system || '';
const category = metadata.archive_category || ''; const category = metadata.archive_category || '';
const tag = metadata.tag || ''; const tag = metadata.tag || '';
const provideTime = metadata.provide_time || ''; const provideTime = metadata.provide_time || '';
...@@ -289,6 +374,8 @@ export function normalizeMaterialRecord(record = {}) { ...@@ -289,6 +374,8 @@ export function normalizeMaterialRecord(record = {}) {
fileUrl: fileAsset.url || '', fileUrl: fileAsset.url || '',
sourceApp, sourceApp,
sourceAppLabel: getSourceAppLabel(sourceApp), sourceAppLabel: getSourceAppLabel(sourceApp),
sourceSystem,
sourceSystemLabel: getSourceSystemLabel(sourceSystem),
archiveCategory: category || UNCATEGORIZED_CATEGORY_LABEL, archiveCategory: category || UNCATEGORIZED_CATEGORY_LABEL,
tag, tag,
remark: metadata.remark || '', remark: metadata.remark || '',
...@@ -314,35 +401,76 @@ function isUncategorizedCategory(category = '') { ...@@ -314,35 +401,76 @@ function isUncategorizedCategory(category = '') {
return String(category || '').trim() === UNCATEGORIZED_CATEGORY_LABEL; return String(category || '').trim() === UNCATEGORIZED_CATEGORY_LABEL;
} }
function normalizeFilterValue(value = '') {
return String(value || '').trim();
}
function buildFuzzyFilter(value = '') {
return {
$regex: normalizeFilterValue(value),
$options: 'i',
};
}
function normalizeSourceAppFilter(value = '') {
const normalizedValue = normalizeFilterValue(value);
const matchedOption = SOURCE_APP_SUGGESTIONS.find(
(item) => item.value === normalizedValue || item.label === normalizedValue,
);
return matchedOption?.value || normalizedValue;
}
function normalizeSourceSystemFilter(value = '') {
const normalizedValue = normalizeFilterValue(value);
const matchedOption = SOURCE_SYSTEM_SUGGESTIONS.find(
(item) => item.value === normalizedValue || item.label === normalizedValue,
);
return matchedOption?.value || normalizedValue;
}
function includesFuzzy(value = '', keyword = '') {
const normalizedKeyword = normalizeFilterValue(keyword).toLowerCase();
return (
!normalizedKeyword ||
String(value || '').toLowerCase().includes(normalizedKeyword)
);
}
function parseBooleanFilter(value = '') {
if (value === true || value === 'true') {
return true;
}
if (value === false || value === 'false') {
return false;
}
return undefined;
}
function applyClientFilters(materials, filters = {}) { function applyClientFilters(materials, filters = {}) {
const keyword = String(filters.keyword || '').trim().toLowerCase(); const archiveCategory = normalizeFilterValue(filters.archiveCategory);
const sourceApp = String(filters.sourceApp || '').trim();
const archiveCategory = String(filters.archiveCategory || '').trim();
const tag = String(filters.tag || '').trim().toLowerCase();
return materials.filter((item) => { return materials.filter((item) => {
const matchesKeyword =
!keyword ||
[
item.materialTitle,
item.fileName,
item.archiveCategory,
item.tag,
item.sourceAppLabel,
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword));
const matchesSource = !sourceApp || item.sourceApp === sourceApp;
const matchesCategory = const matchesCategory =
!archiveCategory || !archiveCategory ||
(isUncategorizedCategory(archiveCategory) (isUncategorizedCategory(archiveCategory)
? !String(item.rawMetadata?.archive_category || '').trim() ? !String(item.rawMetadata?.archive_category || '').trim()
: item.archiveCategory === archiveCategory); : true);
const matchesTag = const matchesSearchFields = MATERIAL_SEARCH_FIELDS.every(
!tag || String(item.tag || '').toLowerCase().includes(tag); ({ filterKey, valueGetter }) =>
includesFuzzy(valueGetter(item), filters[filterKey]),
);
const matchesBooleanFields = MATERIAL_BOOLEAN_SEARCH_FIELDS.every(
({ filterKey, valueGetter }) => {
const parsedValue = parseBooleanFilter(filters[filterKey]);
return parsedValue === undefined || valueGetter(item) === parsedValue;
},
);
return matchesKeyword && matchesSource && matchesCategory && matchesTag; return matchesCategory && matchesSearchFields && matchesBooleanFields;
}); });
} }
...@@ -394,35 +522,71 @@ function extractCount(response) { ...@@ -394,35 +522,71 @@ function extractCount(response) {
} }
function buildRecordFilter(filters = {}) { function buildRecordFilter(filters = {}) {
const filter = {}; const conditions = [];
MATERIAL_SEARCH_FIELDS.forEach(({ filterKey, recordPath }) => {
const value =
filterKey === 'sourceApp'
? normalizeSourceAppFilter(filters[filterKey])
: filterKey === 'sourceSystem'
? normalizeSourceSystemFilter(filters[filterKey])
: normalizeFilterValue(filters[filterKey]);
if (!value || filterKey === 'archiveCategory') {
return;
}
conditions.push({
[recordPath]: buildFuzzyFilter(value),
});
});
if (filters.sourceApp) { MATERIAL_BOOLEAN_SEARCH_FIELDS.forEach(({ filterKey, recordPath }) => {
filter['metadata.source_app'] = filters.sourceApp; const value = parseBooleanFilter(filters[filterKey]);
if (value === undefined) {
return;
}
conditions.push({
[recordPath]: value,
});
});
const archiveCategory = normalizeFilterValue(filters.archiveCategory);
if (archiveCategory) {
conditions.push(
isUncategorizedCategory(archiveCategory)
? {
$or: [
{ 'metadata.archive_category': '' },
{ 'metadata.archive_category': null },
{ 'metadata.archive_category': { $exists: false } },
],
}
: { 'metadata.archive_category': buildFuzzyFilter(archiveCategory) },
);
} }
if (filters.archiveCategory) { if (conditions.length === 0) {
filter['$or'] = isUncategorizedCategory(filters.archiveCategory) return {};
? [
{ 'metadata.archive_category': '' },
{ 'metadata.archive_category': null },
{ 'metadata.archive_category': { $exists: false } },
]
: [{ 'metadata.archive_category': filters.archiveCategory }];
} }
if (filters.tag) { if (conditions.length === 1) {
filter['metadata.tag'] = filters.tag; return conditions[0];
} }
return filter; return {
$and: conditions,
};
} }
function hasActiveFilters(filters = {}) { function hasActiveFilters(filters = {}) {
return Boolean( return (
String(filters.keyword || '').trim() || MATERIAL_SEARCH_FIELDS.some(({ filterKey }) =>
String(filters.sourceApp || '').trim() || Boolean(normalizeFilterValue(filters[filterKey])),
String(filters.archiveCategory || '').trim() || ) ||
String(filters.tag || '').trim(), MATERIAL_BOOLEAN_SEARCH_FIELDS.some(
({ filterKey }) => parseBooleanFilter(filters[filterKey]) !== undefined,
)
); );
} }
...@@ -448,6 +612,11 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -448,6 +612,11 @@ export function buildMaterialDashboard(materials = [], options = {}) {
(item) => item.sourceApp || 'unknown', (item) => item.sourceApp || 'unknown',
(item) => item.sourceAppLabel, (item) => item.sourceAppLabel,
); );
const sourceSystemGroups = buildGroup(
materials,
(item) => item.sourceSystem || 'unknown',
(item) => item.sourceSystemLabel,
);
const categoryGroups = buildGroup( const categoryGroups = buildGroup(
materials, materials,
(item) => item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL, (item) => item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL,
...@@ -489,6 +658,9 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -489,6 +658,9 @@ export function buildMaterialDashboard(materials = [], options = {}) {
if (!item.sourceApp) { if (!item.sourceApp) {
attentionReasons.push('缺少来源应用'); attentionReasons.push('缺少来源应用');
} }
if (!item.sourceSystem) {
attentionReasons.push('缺少来源系统');
}
if (!item.rawMetadata?.archive_category) { if (!item.rawMetadata?.archive_category) {
attentionReasons.push('缺少材料分类'); attentionReasons.push('缺少材料分类');
} }
...@@ -513,6 +685,9 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -513,6 +685,9 @@ export function buildMaterialDashboard(materials = [], options = {}) {
const uniqueSourceCount = new Set( const uniqueSourceCount = new Set(
materials.map((item) => item.sourceApp).filter(Boolean), materials.map((item) => item.sourceApp).filter(Boolean),
).size; ).size;
const uniqueSourceSystemCount = new Set(
materials.map((item) => item.sourceSystem).filter(Boolean),
).size;
const uniqueCategoryCount = new Set( const uniqueCategoryCount = new Set(
materials.map((item) => item.archiveCategory).filter(Boolean), materials.map((item) => item.archiveCategory).filter(Boolean),
).size; ).size;
...@@ -523,6 +698,7 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -523,6 +698,7 @@ export function buildMaterialDashboard(materials = [], options = {}) {
totalBytesLabel: formatFileSize(totalBytes), totalBytesLabel: formatFileSize(totalBytes),
recentCount, recentCount,
uniqueSourceCount, uniqueSourceCount,
uniqueSourceSystemCount,
uniqueCategoryCount, uniqueCategoryCount,
confidentialCount, confidentialCount,
auditDeptCount, auditDeptCount,
...@@ -530,6 +706,7 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -530,6 +706,7 @@ export function buildMaterialDashboard(materials = [], options = {}) {
missingProvideDepartmentCount, missingProvideDepartmentCount,
missingProvideTimeCount, missingProvideTimeCount,
sourceGroups, sourceGroups,
sourceSystemGroups,
categoryGroups, categoryGroups,
tagGroups, tagGroups,
provideDepartmentGroups, provideDepartmentGroups,
...@@ -544,6 +721,15 @@ export function buildMaterialDashboard(materials = [], options = {}) { ...@@ -544,6 +721,15 @@ export function buildMaterialDashboard(materials = [], options = {}) {
label: item.label, label: item.label,
})), })),
], ],
sourceSystemSuggestions: [
...SOURCE_SYSTEM_SUGGESTIONS,
...sourceSystemGroups
.filter((item) => !SOURCE_SYSTEM_LABEL_MAP[item.key])
.map((item) => ({
value: item.key,
label: item.label,
})),
],
categorySuggestions: categoryGroups.map((item) => item.label), categorySuggestions: categoryGroups.map((item) => item.label),
tagSuggestions: tagGroups.map((item) => item.label), tagSuggestions: tagGroups.map((item) => item.label),
}; };
...@@ -556,7 +742,6 @@ async function queryMaterialRecords(filters = {}) { ...@@ -556,7 +742,6 @@ async function queryMaterialRecords(filters = {}) {
subject: MATERIAL_SUBJECT, subject: MATERIAL_SUBJECT,
page: filters.page || 0, page: filters.page || 0,
page_size: filters.pageSize || 80, page_size: filters.pageSize || 80,
keyword: String(filters.keyword || '').trim() || undefined,
filter, filter,
sort: '-updated_at -created_at', sort: '-updated_at -created_at',
}); });
...@@ -568,7 +753,6 @@ export async function loadMaterialPage(filters = {}) { ...@@ -568,7 +753,6 @@ export async function loadMaterialPage(filters = {}) {
const page = Number(filters.page || 0); const page = Number(filters.page || 0);
const pageSize = Number(filters.pageSize || 36); const pageSize = Number(filters.pageSize || 36);
const filter = buildRecordFilter(filters); const filter = buildRecordFilter(filters);
const keyword = String(filters.keyword || '').trim() || undefined;
try { try {
const [materials, totalResponse] = await Promise.all([ const [materials, totalResponse] = await Promise.all([
...@@ -579,7 +763,6 @@ export async function loadMaterialPage(filters = {}) { ...@@ -579,7 +763,6 @@ export async function loadMaterialPage(filters = {}) {
}), }),
countRecords({ countRecords({
subject: MATERIAL_SUBJECT, subject: MATERIAL_SUBJECT,
keyword,
filter, filter,
}), }),
]); ]);
...@@ -774,6 +957,7 @@ function buildMaterialPayload({ ...@@ -774,6 +957,7 @@ function buildMaterialPayload({
metadata: { metadata: {
material_title: materialTitle, material_title: materialTitle,
source_app: String(values.sourceApp || '').trim(), source_app: String(values.sourceApp || '').trim(),
source_system: String(values.sourceSystem || '').trim(),
archive_category: String(values.archiveCategory || '').trim(), archive_category: String(values.archiveCategory || '').trim(),
uploader: uploader:
currentUser?._id || currentUser?.username || currentUser?.id || '', currentUser?._id || currentUser?.username || currentUser?.id || '',
...@@ -892,6 +1076,7 @@ export async function updateMaterialRecord(material, values) { ...@@ -892,6 +1076,7 @@ export async function updateMaterialRecord(material, values) {
material_title: nextTitle, material_title: nextTitle,
file_asset: material.fileAsset, file_asset: material.fileAsset,
source_app: String(values.sourceApp || '').trim(), source_app: String(values.sourceApp || '').trim(),
source_system: String(values.sourceSystem || '').trim(),
archive_category: String(values.archiveCategory || '').trim(), archive_category: String(values.archiveCategory || '').trim(),
uploader: uploader:
material.rawMetadata?.uploader || material.rawMetadata?.uploader ||
...@@ -980,6 +1165,127 @@ export async function batchRemoveMaterialRecords(materials = []) { ...@@ -980,6 +1165,127 @@ export async function batchRemoveMaterialRecords(materials = []) {
return recordNames.length; return recordNames.length;
} }
function sanitizeFilename(filename = '') {
const normalized = String(filename || '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/[\u0000-\u001f]/g, '')
.trim();
return normalized || '未命名文件';
}
function getUniqueFilename(filename, usedNames) {
const safeName = sanitizeFilename(filename);
if (!usedNames.has(safeName)) {
usedNames.add(safeName);
return safeName;
}
const dotIndex = safeName.lastIndexOf('.');
const basename = dotIndex > 0 ? safeName.slice(0, dotIndex) : safeName;
const extension = dotIndex > 0 ? safeName.slice(dotIndex) : '';
let index = 2;
while (usedNames.has(`${basename} (${index})${extension}`)) {
index += 1;
}
const uniqueName = `${basename} (${index})${extension}`;
usedNames.add(uniqueName);
return uniqueName;
}
function triggerDownload(urlOrBlob, filename) {
const link = document.createElement('a');
const isBlob = urlOrBlob instanceof Blob;
const href = isBlob ? URL.createObjectURL(urlOrBlob) : urlOrBlob;
link.href = href;
link.download = sanitizeFilename(filename);
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (isBlob) {
URL.revokeObjectURL(href);
}
}
async function fetchMaterialFileBlob(material) {
const response = await fetch(material.fileUrl);
if (!response.ok) {
throw new Error(`文件下载失败:${material.fileName || material.materialTitle}`);
}
return response.blob();
}
export async function batchExportMaterialFiles(materials = []) {
if (!Array.isArray(materials) || materials.length === 0) {
throw new Error('请先选择需要下载的材料');
}
const downloadableMaterials = materials.filter((item) => item?.fileUrl);
const missingUrlCount = materials.length - downloadableMaterials.length;
if (downloadableMaterials.length === 0) {
throw new Error('已勾选材料没有可下载的文件地址');
}
if (downloadableMaterials.length === 1) {
const material = downloadableMaterials[0];
const filename = sanitizeFilename(material.fileName || material.materialTitle);
triggerDownload(material.fileUrl, filename);
return {
filename,
downloadedCount: 1,
skippedCount: missingUrlCount,
isArchive: false,
};
}
const zip = new JSZip();
const usedNames = new Set();
const failedMaterials = [];
for (const material of downloadableMaterials) {
try {
const blob = await fetchMaterialFileBlob(material);
const filename = getUniqueFilename(
material.fileName || material.materialTitle,
usedNames,
);
zip.file(filename, blob);
} catch (_error) {
failedMaterials.push(material);
}
}
const zippedCount = downloadableMaterials.length - failedMaterials.length;
if (zippedCount === 0) {
throw new Error('文件下载失败,未能生成压缩包。请检查文件地址是否可访问。');
}
const filename = `${MATERIAL_EXPORT_ZIP_NAME_PREFIX}-${dayjs().format(
'YYYYMMDD-HHmmss',
)}.zip`;
const zipBlob = await zip.generateAsync({ type: 'blob' });
triggerDownload(zipBlob, filename);
return {
filename,
downloadedCount: zippedCount,
skippedCount: missingUrlCount + failedMaterials.length,
isArchive: true,
};
}
export async function removeMaterialRecord(material) { export async function removeMaterialRecord(material) {
const recordName = material?.recordName || material?.id; const recordName = material?.recordName || material?.id;
......
...@@ -26,6 +26,7 @@ import { ...@@ -26,6 +26,7 @@ import {
ArrowUpTrayIcon, ArrowUpTrayIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
ClipboardDocumentIcon, ClipboardDocumentIcon,
ArrowDownTrayIcon,
FolderIcon, FolderIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
SparklesIcon, SparklesIcon,
...@@ -44,6 +45,7 @@ import { ...@@ -44,6 +45,7 @@ import {
import EmptyStatePage from '../EmptyStatePage'; import EmptyStatePage from '../EmptyStatePage';
import { import {
ARCHIVE_UPLOAD_MODE, ARCHIVE_UPLOAD_MODE,
batchExportMaterialFiles,
batchRemoveMaterialRecords, batchRemoveMaterialRecords,
batchUpdateMaterialRecords, batchUpdateMaterialRecords,
buildMaterialDashboard, buildMaterialDashboard,
...@@ -56,6 +58,7 @@ import { ...@@ -56,6 +58,7 @@ import {
removeMaterialRecord, removeMaterialRecord,
UNCATEGORIZED_CATEGORY_LABEL, UNCATEGORIZED_CATEGORY_LABEL,
SOURCE_APP_SUGGESTIONS, SOURCE_APP_SUGGESTIONS,
SOURCE_SYSTEM_SUGGESTIONS,
updateMaterialRecord, updateMaterialRecord,
} from '../materialService'; } from '../materialService';
...@@ -63,6 +66,21 @@ const ALL_CATEGORY_KEY = '__all__'; ...@@ -63,6 +66,21 @@ const ALL_CATEGORY_KEY = '__all__';
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100]; const PAGE_SIZE_OPTIONS = [20, 50, 100];
function buildEmptyMaterialSearchForm() {
return {
materialTitle: '',
fileName: '',
archiveCategory: '',
tag: '',
sourceApp: '',
sourceSystem: '',
provideDepartment: '',
remark: '',
isConfidential: '',
isAuditDeptMaterial: '',
};
}
function Panel({ className, children }) { function Panel({ className, children }) {
return ( return (
<section <section
...@@ -124,50 +142,6 @@ function CategoryTab({ active, label, count, icon: Icon, onClick }) { ...@@ -124,50 +142,6 @@ function CategoryTab({ active, label, count, icon: Icon, onClick }) {
); );
} }
function FileVisual({ material, compact = false }) {
if (material?.isImage && material?.fileUrl) {
return (
<div
className={clsx(
'overflow-hidden rounded-[22px] border border-zinc-200 bg-zinc-100',
compact ? 'h-18 w-18' : 'h-60 w-full',
)}
>
<img
src={material.fileUrl}
alt={material.materialTitle}
className="h-full w-full object-cover"
/>
</div>
);
}
return (
<div
className={clsx(
'flex items-center justify-center rounded-[22px] border border-zinc-200 bg-[linear-gradient(135deg,rgba(225,29,72,0.12),rgba(190,24,93,0.03))] text-zinc-950',
compact ? 'h-18 w-18' : 'h-60 w-full',
)}
>
<div className="text-center">
<div
className={clsx(
'font-semibold tracking-[0.2em]',
compact ? 'text-xs' : 'text-sm',
)}
>
{material?.extension || 'FILE'}
</div>
{!compact ? (
<div className="mt-2 text-sm text-zinc-500">
{material?.fileName || '未上传文件'}
</div>
) : null}
</div>
</div>
);
}
function MaterialListRow({ material, onOpen, checked, onToggle }) { function MaterialListRow({ material, onOpen, checked, onToggle }) {
return ( return (
<TableRow <TableRow
...@@ -206,6 +180,7 @@ function MaterialListRow({ material, onOpen, checked, onToggle }) { ...@@ -206,6 +180,7 @@ function MaterialListRow({ material, onOpen, checked, onToggle }) {
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-zinc-600">{material.sourceAppLabel}</TableCell> <TableCell className="text-zinc-600">{material.sourceAppLabel}</TableCell>
<TableCell className="text-zinc-600">{material.sourceSystemLabel}</TableCell>
<TableCell className="text-zinc-600">{material.uploaderLabel}</TableCell> <TableCell className="text-zinc-600">{material.uploaderLabel}</TableCell>
<TableCell className="text-zinc-600">{formatDateTime(material.uploadedAt)}</TableCell> <TableCell className="text-zinc-600">{formatDateTime(material.uploadedAt)}</TableCell>
</TableRow> </TableRow>
...@@ -216,6 +191,7 @@ function buildEmptyUploadForm() { ...@@ -216,6 +191,7 @@ function buildEmptyUploadForm() {
return { return {
materialTitle: '', materialTitle: '',
sourceApp: '', sourceApp: '',
sourceSystem: '',
archiveCategory: '', archiveCategory: '',
tag: '', tag: '',
provideDepartment: '', provideDepartment: '',
...@@ -229,6 +205,7 @@ function buildEmptyUploadForm() { ...@@ -229,6 +205,7 @@ function buildEmptyUploadForm() {
function buildEmptyBatchEditForm() { function buildEmptyBatchEditForm() {
return { return {
sourceApp: '', sourceApp: '',
sourceSystem: '',
archiveCategory: '', archiveCategory: '',
provideDepartment: '', provideDepartment: '',
provideTime: '', provideTime: '',
...@@ -240,6 +217,7 @@ function buildEmptyBatchEditForm() { ...@@ -240,6 +217,7 @@ function buildEmptyBatchEditForm() {
function buildEmptyBatchFieldSelection() { function buildEmptyBatchFieldSelection() {
return { return {
sourceApp: false, sourceApp: false,
sourceSystem: false,
archiveCategory: false, archiveCategory: false,
provideDepartment: false, provideDepartment: false,
provideTime: false, provideTime: false,
...@@ -276,6 +254,8 @@ function buildDetailForm(material) { ...@@ -276,6 +254,8 @@ function buildDetailForm(material) {
return { return {
materialTitle: material.materialTitle || '', materialTitle: material.materialTitle || '',
sourceApp: material.rawMetadata?.source_app || material.sourceApp || '', sourceApp: material.rawMetadata?.source_app || material.sourceApp || '',
sourceSystem:
material.rawMetadata?.source_system || material.sourceSystem || '',
archiveCategory: archiveCategory:
material.rawMetadata?.archive_category || material.archiveCategory || '', material.rawMetadata?.archive_category || material.archiveCategory || '',
tag: material.rawMetadata?.tag || material.tag || '', tag: material.rawMetadata?.tag || material.tag || '',
...@@ -345,10 +325,10 @@ function mergeCategoryOptions(previousOptions, nextMaterials) { ...@@ -345,10 +325,10 @@ function mergeCategoryOptions(previousOptions, nextMaterials) {
export default function ResourceBookingPage() { export default function ResourceBookingPage() {
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const [query, setQuery] = useState(''); const [materialSearchForm, setMaterialSearchForm] = useState(
const deferredQuery = useDeferredValue(query); buildEmptyMaterialSearchForm,
const [sourceAppFilter, setSourceAppFilter] = useState(''); );
const [tagFilter, setTagFilter] = useState(''); const deferredMaterialSearchForm = useDeferredValue(materialSearchForm);
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY); const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
...@@ -360,11 +340,13 @@ export default function ResourceBookingPage() { ...@@ -360,11 +340,13 @@ export default function ResourceBookingPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const [isDemoMode, setIsDemoMode] = useState(false); const [isDemoMode, setIsDemoMode] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isDetailEditing, setIsDetailEditing] = useState(false);
const [isBatchEditOpen, setIsBatchEditOpen] = useState(false); const [isBatchEditOpen, setIsBatchEditOpen] = useState(false);
const [isBatchTagOpen, setIsBatchTagOpen] = useState(false); const [isBatchTagOpen, setIsBatchTagOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
...@@ -384,6 +366,7 @@ export default function ResourceBookingPage() { ...@@ -384,6 +366,7 @@ export default function ResourceBookingPage() {
useEffect(() => { useEffect(() => {
setDetailForm(buildDetailForm(activeMaterial)); setDetailForm(buildDetailForm(activeMaterial));
setIsDetailEditing(false);
}, [activeMaterial]); }, [activeMaterial]);
useEffect(() => { useEffect(() => {
...@@ -392,11 +375,7 @@ export default function ResourceBookingPage() { ...@@ -392,11 +375,7 @@ export default function ResourceBookingPage() {
async function runFirstPage() { async function runFirstPage() {
setLoading(true); setLoading(true);
const result = await loadMaterialPage({ const result = await loadMaterialPage({
keyword: deferredQuery, ...deferredMaterialSearchForm,
sourceApp: sourceAppFilter,
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page, page,
pageSize, pageSize,
}); });
...@@ -434,7 +413,7 @@ export default function ResourceBookingPage() { ...@@ -434,7 +413,7 @@ export default function ResourceBookingPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activeCategory, activeMaterialId, deferredQuery, page, pageSize, sourceAppFilter, tagFilter]); }, [activeMaterialId, deferredMaterialSearchForm, page, pageSize]);
const sourceOptions = useMemo(() => { const sourceOptions = useMemo(() => {
const dynamicOptions = (dashboard.sourceSuggestions || []).filter(Boolean); const dynamicOptions = (dashboard.sourceSuggestions || []).filter(Boolean);
...@@ -449,6 +428,19 @@ export default function ResourceBookingPage() { ...@@ -449,6 +428,19 @@ export default function ResourceBookingPage() {
[], [],
); );
}, [dashboard.sourceSuggestions]); }, [dashboard.sourceSuggestions]);
const sourceSystemOptions = useMemo(() => {
const dynamicOptions = (dashboard.sourceSystemSuggestions || []).filter(Boolean);
return [...SOURCE_SYSTEM_SUGGESTIONS, ...dynamicOptions].reduce(
(accumulator, item) => {
if (!item?.value || accumulator.some((current) => current.value === item.value)) {
return accumulator;
}
accumulator.push(item);
return accumulator;
},
[],
);
}, [dashboard.sourceSystemSuggestions]);
const tagSuggestions = dashboard.tagSuggestions || []; const tagSuggestions = dashboard.tagSuggestions || [];
const archiveCategorySuggestions = dashboard.categorySuggestions || []; const archiveCategorySuggestions = dashboard.categorySuggestions || [];
...@@ -475,7 +467,7 @@ export default function ResourceBookingPage() { ...@@ -475,7 +467,7 @@ export default function ResourceBookingPage() {
activeCategory !== ALL_CATEGORY_KEY && activeCategory !== ALL_CATEGORY_KEY &&
!categoryTabs.some((item) => item.key === activeCategory) !categoryTabs.some((item) => item.key === activeCategory)
) { ) {
setActiveCategory(ALL_CATEGORY_KEY); handleCategoryTabClick(ALL_CATEGORY_KEY);
} }
}, [activeCategory, categoryTabs]); }, [activeCategory, categoryTabs]);
...@@ -485,16 +477,7 @@ export default function ResourceBookingPage() { ...@@ -485,16 +477,7 @@ export default function ResourceBookingPage() {
); );
}, [materials]); }, [materials]);
const visibleMaterials = useMemo(() => { const visibleMaterials = materials;
if (activeCategory === ALL_CATEGORY_KEY) {
return materials;
}
return materials.filter(
(item) =>
(item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL) === activeCategory,
);
}, [activeCategory, materials]);
const selectedMaterials = useMemo( const selectedMaterials = useMemo(
() => visibleMaterials.filter((item) => selectedMaterialIds.includes(item.id)), () => visibleMaterials.filter((item) => selectedMaterialIds.includes(item.id)),
...@@ -518,13 +501,33 @@ export default function ResourceBookingPage() { ...@@ -518,13 +501,33 @@ export default function ResourceBookingPage() {
setPage(0); setPage(0);
} }
function updateMaterialSearchField(fieldName, value) {
setMaterialSearchForm((prev) => ({
...prev,
[fieldName]: value,
}));
setActiveCategory(ALL_CATEGORY_KEY);
setPage(0);
}
function resetMaterialSearch() {
setMaterialSearchForm(buildEmptyMaterialSearchForm());
setActiveCategory(ALL_CATEGORY_KEY);
setPage(0);
}
function handleCategoryTabClick(categoryKey) {
setActiveCategory(categoryKey);
setMaterialSearchForm((prev) => ({
...prev,
archiveCategory: categoryKey === ALL_CATEGORY_KEY ? '' : categoryKey,
}));
setPage(0);
}
async function reloadMaterials(options = {}) { async function reloadMaterials(options = {}) {
const result = await loadMaterialPage({ const result = await loadMaterialPage({
keyword: deferredQuery, ...deferredMaterialSearchForm,
sourceApp: sourceAppFilter,
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: options.page ?? page, page: options.page ?? page,
pageSize, pageSize,
}); });
...@@ -595,10 +598,38 @@ export default function ResourceBookingPage() { ...@@ -595,10 +598,38 @@ export default function ResourceBookingPage() {
function openMaterialDetail(material) { function openMaterialDetail(material) {
setActiveMaterialId(material.id); setActiveMaterialId(material.id);
setIsDetailOpen(true); setIsDetailOpen(true);
setIsDetailEditing(false);
setError('');
setNotice('');
}
function handleCloseDetail() {
if (saving) {
return;
}
setIsDetailOpen(false);
setIsDetailEditing(false);
setDetailForm(buildDetailForm(activeMaterial));
}
function handleStartDetailEdit() {
if (!activeMaterial) {
return;
}
setDetailForm(buildDetailForm(activeMaterial));
setIsDetailEditing(true);
setError(''); setError('');
setNotice(''); setNotice('');
} }
function handleCancelDetailEdit() {
setDetailForm(buildDetailForm(activeMaterial));
setIsDetailEditing(false);
setError('');
}
async function handleUploadSubmit() { async function handleUploadSubmit() {
if (!selectedFile) { if (!selectedFile) {
setNotice(''); setNotice('');
...@@ -676,6 +707,7 @@ export default function ResourceBookingPage() { ...@@ -676,6 +707,7 @@ export default function ResourceBookingPage() {
setNotice('材料信息已更新。'); setNotice('材料信息已更新。');
await reloadMaterials(); await reloadMaterials();
setActiveMaterialId(updated.id || activeMaterial.id); setActiveMaterialId(updated.id || activeMaterial.id);
setIsDetailEditing(false);
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '材料信息保存失败,请稍后重试。'); setError(saveError?.message || '材料信息保存失败,请稍后重试。');
} finally { } finally {
...@@ -730,6 +762,7 @@ export default function ResourceBookingPage() { ...@@ -730,6 +762,7 @@ export default function ResourceBookingPage() {
prev.filter((id) => id !== activeMaterial.id), prev.filter((id) => id !== activeMaterial.id),
); );
setIsDetailOpen(false); setIsDetailOpen(false);
setIsDetailEditing(false);
setActiveMaterialId(''); setActiveMaterialId('');
await reloadMaterials({ await reloadMaterials({
page: visibleMaterials.length === 1 && page > 0 ? page - 1 : page, page: visibleMaterials.length === 1 && page > 0 ? page - 1 : page,
...@@ -763,6 +796,9 @@ export default function ResourceBookingPage() { ...@@ -763,6 +796,9 @@ export default function ResourceBookingPage() {
sourceApp: batchEditFields.sourceApp sourceApp: batchEditFields.sourceApp
? batchEditForm.sourceApp ? batchEditForm.sourceApp
: material.rawMetadata?.source_app || material.sourceApp || '', : material.rawMetadata?.source_app || material.sourceApp || '',
sourceSystem: batchEditFields.sourceSystem
? batchEditForm.sourceSystem
: material.rawMetadata?.source_system || material.sourceSystem || '',
archiveCategory: batchEditFields.archiveCategory archiveCategory: batchEditFields.archiveCategory
? batchEditForm.archiveCategory ? batchEditForm.archiveCategory
: material.rawMetadata?.archive_category || : material.rawMetadata?.archive_category ||
...@@ -818,6 +854,8 @@ export default function ResourceBookingPage() { ...@@ -818,6 +854,8 @@ export default function ResourceBookingPage() {
await batchUpdateMaterialRecords(selectedMaterials, (material) => ({ await batchUpdateMaterialRecords(selectedMaterials, (material) => ({
materialTitle: material.materialTitle, materialTitle: material.materialTitle,
sourceApp: material.rawMetadata?.source_app || material.sourceApp || '', sourceApp: material.rawMetadata?.source_app || material.sourceApp || '',
sourceSystem:
material.rawMetadata?.source_system || material.sourceSystem || '',
archiveCategory: archiveCategory:
material.rawMetadata?.archive_category || material.archiveCategory || '', material.rawMetadata?.archive_category || material.archiveCategory || '',
tag: String(batchTagForm.tag || '').trim(), tag: String(batchTagForm.tag || '').trim(),
...@@ -844,6 +882,34 @@ export default function ResourceBookingPage() { ...@@ -844,6 +882,34 @@ export default function ResourceBookingPage() {
} }
} }
async function handleBatchExport() {
if (selectedMaterials.length === 0) {
setError('请先勾选需要下载的材料。');
return;
}
setExporting(true);
setError('');
setNotice('');
try {
const result = await batchExportMaterialFiles(selectedMaterials);
const skippedText = result.skippedCount
? `,${result.skippedCount} 份材料因缺少或无法访问文件地址已跳过`
: '';
setNotice(
result.isArchive
? `已将 ${result.downloadedCount} 份材料打包为压缩包“${result.filename}”${skippedText}。`
: `已开始下载文件“${result.filename}”${skippedText}。`,
);
} catch (exportError) {
setError(exportError?.message || '批量下载失败,请稍后重试。');
} finally {
setExporting(false);
}
}
async function handleBatchDelete() { async function handleBatchDelete() {
if (selectedMaterials.length === 0) { if (selectedMaterials.length === 0) {
setError('请先勾选需要删除的材料。'); setError('请先勾选需要删除的材料。');
...@@ -913,12 +979,7 @@ export default function ResourceBookingPage() { ...@@ -913,12 +979,7 @@ export default function ResourceBookingPage() {
<Button <Button
outline outline
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={resetMaterialSearch}
setQuery('');
setSourceAppFilter('');
setTagFilter('');
setActiveCategory(ALL_CATEGORY_KEY);
}}
> >
<SparklesIcon className="size-4" /> <SparklesIcon className="size-4" />
重置筛选 重置筛选
...@@ -948,49 +1009,163 @@ export default function ResourceBookingPage() { ...@@ -948,49 +1009,163 @@ export default function ResourceBookingPage() {
</Panel> </Panel>
<Panel className="p-5 sm:p-6"> <Panel className="p-5 sm:p-6">
<div className="grid gap-4 xl:grid-cols-[1.5fr_0.8fr_0.8fr]"> <div>
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-zinc-600">
<MagnifyingGlassIcon className="size-4" />
搜索材料
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">搜索材料</div> <div className="mb-2 text-sm font-medium text-zinc-600">材料标题</div>
<InputGroup> <InputGroup>
<MagnifyingGlassIcon data-slot="icon" /> <MagnifyingGlassIcon data-slot="icon" />
<Input <Input
value={query} value={materialSearchForm.materialTitle}
onChange={(event) => setQuery(event.target.value)} onChange={(event) =>
placeholder="按标题、编号或文件名搜索" updateMaterialSearchField('materialTitle', event.target.value)
}
placeholder="例如:函证回函"
/> />
</InputGroup> </InputGroup>
</div> </div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">文件名</div>
<Input
value={materialSearchForm.fileName}
onChange={(event) =>
updateMaterialSearchField('fileName', event.target.value)
}
placeholder="例如:底稿.pdf"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">材料分类</div>
<Input
list="archive-category-search-suggestions"
value={materialSearchForm.archiveCategory}
onChange={(event) =>
updateMaterialSearchField('archiveCategory', event.target.value)
}
placeholder="例如:审计证据"
/>
<datalist id="archive-category-search-suggestions">
{archiveCategorySuggestions.map((item) => (
<option key={item} value={item} />
))}
</datalist>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签分组</div>
<Input
list="tag-search-suggestions"
value={materialSearchForm.tag}
onChange={(event) =>
updateMaterialSearchField('tag', event.target.value)
}
placeholder="例如:项目一期"
/>
<datalist id="tag-search-suggestions">
{tagSuggestions.map((item) => (
<option key={item} value={item} />
))}
</datalist>
</div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">来源应用</div> <div className="mb-2 text-sm font-medium text-zinc-600">来源应用</div>
<select <Input
value={sourceAppFilter} list="source-app-search-suggestions"
onChange={(event) => setSourceAppFilter(event.target.value)} value={materialSearchForm.sourceApp}
className="block w-full rounded-lg border border-zinc-200 bg-white px-3 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-100" onChange={(event) =>
> updateMaterialSearchField('sourceApp', event.target.value)
<option value="">全部来源</option> }
placeholder="例如:材料中心 / 中台"
/>
<datalist id="source-app-search-suggestions">
{sourceOptions.map((item) => ( {sourceOptions.map((item) => (
<option key={item.value} value={item.value}> <option key={item.value} value={item.value}>
{item.label} {item.label}
</option> </option>
))} ))}
</select> </datalist>
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签分组</div> <div className="mb-2 text-sm font-medium text-zinc-600">来源系统</div>
<Input <Input
list="tag-suggestions" list="source-system-search-suggestions"
value={tagFilter} value={materialSearchForm.sourceSystem}
onChange={(event) => setTagFilter(event.target.value)} onChange={(event) =>
placeholder="例如:项目一期" updateMaterialSearchField('sourceSystem', event.target.value)
}
placeholder="例如:审计材料管理系统 / 中台"
/> />
<datalist id="tag-suggestions"> <datalist id="source-system-search-suggestions">
{tagSuggestions.map((item) => ( {sourceSystemOptions.map((item) => (
<option key={item} value={item} /> <option key={item.value} value={item.value}>
{item.label}
</option>
))} ))}
</datalist> </datalist>
</div> </div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">提供部门</div>
<Input
value={materialSearchForm.provideDepartment}
onChange={(event) =>
updateMaterialSearchField('provideDepartment', event.target.value)
}
placeholder="例如:财务部"
/>
</div>
<div className="md:col-span-2">
<div className="mb-2 text-sm font-medium text-zinc-600">备注</div>
<Input
value={materialSearchForm.remark}
onChange={(event) =>
updateMaterialSearchField('remark', event.target.value)
}
placeholder="输入备注中的任意片段"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">是否涉密</div>
<Select
value={materialSearchForm.isConfidential}
onChange={(event) =>
updateMaterialSearchField('isConfidential', event.target.value)
}
>
<option value="">全部</option>
<option value="true">是</option>
<option value="false">否</option>
</Select>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">是否审计部材料</div>
<Select
value={materialSearchForm.isAuditDeptMaterial}
onChange={(event) =>
updateMaterialSearchField(
'isAuditDeptMaterial',
event.target.value,
)
}
>
<option value="">全部</option>
<option value="true">是</option>
<option value="false">否</option>
</Select>
</div>
</div> </div>
<div className="mt-6 flex flex-wrap gap-2"> <div className="mt-6 flex flex-wrap gap-2">
...@@ -1001,7 +1176,7 @@ export default function ResourceBookingPage() { ...@@ -1001,7 +1176,7 @@ export default function ResourceBookingPage() {
label={item.label} label={item.label}
count={item.count} count={item.count}
icon={item.icon} icon={item.icon}
onClick={() => setActiveCategory(item.key)} onClick={() => handleCategoryTabClick(item.key)}
/> />
))} ))}
</div> </div>
...@@ -1068,7 +1243,7 @@ export default function ResourceBookingPage() { ...@@ -1068,7 +1243,7 @@ export default function ResourceBookingPage() {
已勾选 {selectedCount} 份材料 已勾选 {selectedCount} 份材料
</div> </div>
<div className="mt-1 text-sm text-zinc-500"> <div className="mt-1 text-sm text-zinc-500">
支持对当前页已勾选材料进行批量修改、批量删除和批量打标签。 支持对当前页已勾选材料进行批量下载/导出、批量修改、批量删除和批量打标签。
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
...@@ -1076,10 +1251,19 @@ export default function ResourceBookingPage() { ...@@ -1076,10 +1251,19 @@ export default function ResourceBookingPage() {
plain plain
className="cursor-pointer" className="cursor-pointer"
onClick={clearSelection} onClick={clearSelection}
disabled={selectedCount === 0 || saving} disabled={selectedCount === 0 || saving || exporting}
> >
清空勾选 清空勾选
</Button> </Button>
<Button
outline
className="cursor-pointer"
onClick={handleBatchExport}
disabled={selectedCount === 0 || saving || exporting}
>
<ArrowDownTrayIcon className="size-4" />
{exporting ? '导出中...' : '批量下载/导出'}
</Button>
<Button <Button
outline outline
className="cursor-pointer" className="cursor-pointer"
...@@ -1087,7 +1271,7 @@ export default function ResourceBookingPage() { ...@@ -1087,7 +1271,7 @@ export default function ResourceBookingPage() {
resetBatchEditState(); resetBatchEditState();
setIsBatchEditOpen(true); setIsBatchEditOpen(true);
}} }}
disabled={selectedCount === 0 || saving} disabled={selectedCount === 0 || saving || exporting}
> >
批量修改 批量修改
</Button> </Button>
...@@ -1098,7 +1282,7 @@ export default function ResourceBookingPage() { ...@@ -1098,7 +1282,7 @@ export default function ResourceBookingPage() {
resetBatchTagState(); resetBatchTagState();
setIsBatchTagOpen(true); setIsBatchTagOpen(true);
}} }}
disabled={selectedCount === 0 || saving} disabled={selectedCount === 0 || saving || exporting}
> >
批量打标签 批量打标签
</Button> </Button>
...@@ -1106,7 +1290,7 @@ export default function ResourceBookingPage() { ...@@ -1106,7 +1290,7 @@ export default function ResourceBookingPage() {
color="red" color="red"
className="cursor-pointer" className="cursor-pointer"
onClick={handleBatchDelete} onClick={handleBatchDelete}
disabled={selectedCount === 0 || saving} disabled={selectedCount === 0 || saving || exporting}
> >
批量删除 批量删除
</Button> </Button>
...@@ -1129,6 +1313,7 @@ export default function ResourceBookingPage() { ...@@ -1129,6 +1313,7 @@ export default function ResourceBookingPage() {
<TableHeader>文件信息</TableHeader> <TableHeader>文件信息</TableHeader>
<TableHeader>分类 / 标签</TableHeader> <TableHeader>分类 / 标签</TableHeader>
<TableHeader>来源应用</TableHeader> <TableHeader>来源应用</TableHeader>
<TableHeader>来源系统</TableHeader>
<TableHeader>上传人</TableHeader> <TableHeader>上传人</TableHeader>
<TableHeader>上传时间</TableHeader> <TableHeader>上传时间</TableHeader>
</TableRow> </TableRow>
...@@ -1192,24 +1377,19 @@ export default function ResourceBookingPage() { ...@@ -1192,24 +1377,19 @@ export default function ResourceBookingPage() {
<Dialog <Dialog
open={isDetailOpen} open={isDetailOpen}
onClose={() => { onClose={handleCloseDetail}
if (saving) {
return;
}
setIsDetailOpen(false);
}}
size="4xl" size="4xl"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<DialogTitle>材料详情</DialogTitle> <DialogTitle>材料详情</DialogTitle>
<DialogDescription> <DialogDescription>
当前详情表单已按最新对象文档拆成基础信息、提供信息与合规属性,便于边查看边维护材料元数据。 默认以只读方式查看材料元数据;点击编辑后再修改,保存成功后生效。
</DialogDescription> </DialogDescription>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setIsDetailOpen(false)} onClick={handleCloseDetail}
disabled={saving} disabled={saving}
aria-label="关闭材料详情" aria-label="关闭材料详情"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-500 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-700 disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex size-10 shrink-0 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-500 shadow-sm transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
...@@ -1222,7 +1402,6 @@ export default function ResourceBookingPage() { ...@@ -1222,7 +1402,6 @@ export default function ResourceBookingPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]"> <div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div className="space-y-4"> <div className="space-y-4">
<FileVisual material={activeMaterial} />
<div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4"> <div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div> <div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500"> <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
...@@ -1287,6 +1466,18 @@ export default function ResourceBookingPage() { ...@@ -1287,6 +1466,18 @@ export default function ResourceBookingPage() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div
className={clsx(
'rounded-2xl border px-4 py-3 text-sm',
isDetailEditing
? 'border-sky-200 bg-sky-50 text-sky-700'
: 'border-zinc-200 bg-zinc-50 text-zinc-600',
)}
>
{isDetailEditing
? '当前处于编辑态,修改后点击保存才会写入材料主档。'
: '当前处于查看态,字段已锁定,点击编辑后可维护材料信息。'}
</div>
<FormSection <FormSection
eyebrow="BASIC INFO" eyebrow="BASIC INFO"
title="基础信息" title="基础信息"
...@@ -1297,6 +1488,7 @@ export default function ResourceBookingPage() { ...@@ -1297,6 +1488,7 @@ export default function ResourceBookingPage() {
<FieldLabel required>材料标题</FieldLabel> <FieldLabel required>材料标题</FieldLabel>
<Input <Input
value={detailForm.materialTitle} value={detailForm.materialTitle}
disabled={!isDetailEditing || saving}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1310,6 +1502,7 @@ export default function ResourceBookingPage() { ...@@ -1310,6 +1502,7 @@ export default function ResourceBookingPage() {
<Input <Input
list="source-app-suggestions" list="source-app-suggestions"
value={detailForm.sourceApp} value={detailForm.sourceApp}
disabled={!isDetailEditing || saving}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1319,11 +1512,27 @@ export default function ResourceBookingPage() { ...@@ -1319,11 +1512,27 @@ export default function ResourceBookingPage() {
placeholder="可选" placeholder="可选"
/> />
</div> </div>
<div>
<FieldLabel>来源系统</FieldLabel>
<Input
list="source-system-suggestions"
value={detailForm.sourceSystem}
disabled={!isDetailEditing || saving}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
sourceSystem: event.target.value,
}))
}
placeholder="可选"
/>
</div>
<div> <div>
<FieldLabel>材料分类</FieldLabel> <FieldLabel>材料分类</FieldLabel>
<Input <Input
list="archive-category-suggestions" list="archive-category-suggestions"
value={detailForm.archiveCategory} value={detailForm.archiveCategory}
disabled={!isDetailEditing || saving}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1338,6 +1547,7 @@ export default function ResourceBookingPage() { ...@@ -1338,6 +1547,7 @@ export default function ResourceBookingPage() {
<Input <Input
list="tag-suggestions" list="tag-suggestions"
value={detailForm.tag} value={detailForm.tag}
disabled={!isDetailEditing || saving}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1360,6 +1570,7 @@ export default function ResourceBookingPage() { ...@@ -1360,6 +1570,7 @@ export default function ResourceBookingPage() {
<FieldLabel>提供部门</FieldLabel> <FieldLabel>提供部门</FieldLabel>
<Input <Input
value={detailForm.provideDepartment} value={detailForm.provideDepartment}
disabled={!isDetailEditing || saving}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1374,6 +1585,7 @@ export default function ResourceBookingPage() { ...@@ -1374,6 +1585,7 @@ export default function ResourceBookingPage() {
<Input <Input
type="datetime-local" type="datetime-local"
value={detailForm.provideTime} value={detailForm.provideTime}
disabled={!isDetailEditing || saving}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1395,6 +1607,7 @@ export default function ResourceBookingPage() { ...@@ -1395,6 +1607,7 @@ export default function ResourceBookingPage() {
<Checkbox <Checkbox
color="sky" color="sky"
checked={detailForm.isConfidential} checked={detailForm.isConfidential}
disabled={!isDetailEditing || saving}
onChange={(checked) => onChange={(checked) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1415,6 +1628,7 @@ export default function ResourceBookingPage() { ...@@ -1415,6 +1628,7 @@ export default function ResourceBookingPage() {
<Checkbox <Checkbox
color="sky" color="sky"
checked={detailForm.isAuditDeptMaterial} checked={detailForm.isAuditDeptMaterial}
disabled={!isDetailEditing || saving}
onChange={(checked) => onChange={(checked) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
...@@ -1441,6 +1655,11 @@ export default function ResourceBookingPage() { ...@@ -1441,6 +1655,11 @@ export default function ResourceBookingPage() {
<option key={item.value} value={item.value} /> <option key={item.value} value={item.value} />
))} ))}
</datalist> </datalist>
<datalist id="source-system-suggestions">
{sourceSystemOptions.map((item) => (
<option key={item.value} value={item.value} />
))}
</datalist>
<datalist id="archive-category-suggestions"> <datalist id="archive-category-suggestions">
{archiveCategorySuggestions.map((item) => ( {archiveCategorySuggestions.map((item) => (
<option key={item} value={item} /> <option key={item} value={item} />
...@@ -1460,14 +1679,35 @@ export default function ResourceBookingPage() { ...@@ -1460,14 +1679,35 @@ export default function ResourceBookingPage() {
> >
{saving ? '处理中...' : '删除材料'} {saving ? '处理中...' : '删除材料'}
</Button> </Button>
<Button {isDetailEditing ? (
color="blue" <>
className="cursor-pointer" <Button
onClick={handleSaveDetail} outline
disabled={saving || !activeMaterial} className="cursor-pointer"
> onClick={handleCancelDetailEdit}
{saving ? '保存中...' : '保存修改'} disabled={saving || !activeMaterial}
</Button> >
取消编辑
</Button>
<Button
color="blue"
className="cursor-pointer"
onClick={handleSaveDetail}
disabled={saving || !activeMaterial}
>
{saving ? '保存中...' : '保存修改'}
</Button>
</>
) : (
<Button
color="blue"
className="cursor-pointer"
onClick={handleStartDetailEdit}
disabled={saving || !activeMaterial}
>
编辑
</Button>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
...@@ -1532,6 +1772,41 @@ export default function ResourceBookingPage() { ...@@ -1532,6 +1772,41 @@ export default function ResourceBookingPage() {
/> />
</div> </div>
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox
color="sky"
checked={batchEditFields.sourceSystem}
onChange={(checked) =>
setBatchEditFields((prev) => ({
...prev,
sourceSystem: Boolean(checked),
}))
}
/>
<div>
<div data-slot="label" className="text-sm font-medium text-zinc-900">
批量更新来源系统
</div>
<div data-slot="description" className="text-sm text-zinc-500">
勾选后可统一覆盖所有已选材料的来源系统。
</div>
</div>
</CheckboxField>
<div>
<FieldLabel>来源系统</FieldLabel>
<Input
list="source-system-suggestions"
value={batchEditForm.sourceSystem}
onChange={(event) =>
setBatchEditForm((prev) => ({
...prev,
sourceSystem: event.target.value,
}))
}
placeholder="可留空,用于统一清空"
/>
</div>
<CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3"> <CheckboxField className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<Checkbox <Checkbox
color="sky" color="sky"
...@@ -1979,6 +2254,20 @@ export default function ResourceBookingPage() { ...@@ -1979,6 +2254,20 @@ export default function ResourceBookingPage() {
placeholder="可选,例如 中台" placeholder="可选,例如 中台"
/> />
</div> </div>
<div>
<FieldLabel>来源系统</FieldLabel>
<Input
list="source-system-suggestions"
value={uploadForm.sourceSystem}
onChange={(event) =>
setUploadForm((prev) => ({
...prev,
sourceSystem: event.target.value,
}))
}
placeholder="可选,例如 审计材料管理系统"
/>
</div>
<div> <div>
<FieldLabel>材料分类</FieldLabel> <FieldLabel>材料分类</FieldLabel>
<Input <Input
...@@ -2105,6 +2394,9 @@ export default function ResourceBookingPage() { ...@@ -2105,6 +2394,9 @@ export default function ResourceBookingPage() {
<Textarea <Textarea
value={[ value={[
uploadForm.sourceApp ? `来源应用:${uploadForm.sourceApp}` : '', uploadForm.sourceApp ? `来源应用:${uploadForm.sourceApp}` : '',
uploadForm.sourceSystem
? `来源系统:${uploadForm.sourceSystem}`
: '',
uploadForm.archiveCategory uploadForm.archiveCategory
? `材料分类:${uploadForm.archiveCategory}` ? `材料分类:${uploadForm.archiveCategory}`
: '', : '',
...@@ -2126,6 +2418,11 @@ export default function ResourceBookingPage() { ...@@ -2126,6 +2418,11 @@ export default function ResourceBookingPage() {
<option key={item.value} value={item.value} /> <option key={item.value} value={item.value} />
))} ))}
</datalist> </datalist>
<datalist id="source-system-suggestions">
{sourceSystemOptions.map((item) => (
<option key={item.value} value={item.value} />
))}
</datalist>
<datalist id="archive-category-suggestions"> <datalist id="archive-category-suggestions">
{archiveCategorySuggestions.map((item) => ( {archiveCategorySuggestions.map((item) => (
<option key={item} value={item} /> <option key={item} value={item} />
......
...@@ -133,6 +133,53 @@ function ChartPanel({ option, className = '', emptyText = '暂无图表数据' } ...@@ -133,6 +133,53 @@ function ChartPanel({ option, className = '', emptyText = '暂无图表数据' }
); );
} }
function ProgressBar({ value, tone = 'emerald' }) {
const normalizedValue = Math.max(0, Math.min(100, value));
const toneClass =
tone === 'rose'
? 'bg-rose-500'
: tone === 'amber'
? 'bg-amber-500'
: tone === 'sky'
? 'bg-sky-500'
: 'bg-emerald-500';
return (
<div className="h-2.5 overflow-hidden rounded-full bg-zinc-100">
<div
className={clsx('h-full rounded-full transition-all duration-300', toneClass)}
style={{ width: `${normalizedValue}%` }}
/>
</div>
);
}
function RatioRow({ label, value, total, tone = 'emerald', icon: Icon }) {
const percent = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="rounded-[20px] border border-zinc-200 bg-white px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{Icon ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-700">
<Icon className="size-5" />
</div>
) : null}
<div className="min-w-0 text-sm font-medium text-zinc-800">{label}</div>
</div>
<div className="shrink-0 text-right">
<div className="text-base font-semibold tabular-nums text-zinc-950">{value}</div>
<div className="text-xs tabular-nums text-zinc-500">{percent}%</div>
</div>
</div>
<div className="mt-3">
<ProgressBar value={percent} tone={tone} />
</div>
</div>
);
}
export default function ResourceStatPage() { export default function ResourceStatPage() {
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
...@@ -310,121 +357,42 @@ export default function ResourceStatPage() { ...@@ -310,121 +357,42 @@ export default function ResourceStatPage() {
}; };
}, [dashboard.categoryGroups, dashboard.uniqueCategoryCount]); }, [dashboard.categoryGroups, dashboard.uniqueCategoryCount]);
const governanceOption = useMemo(() => {
const totalCount = dashboard.totalCount || 0;
const incompleteCount = Math.max(totalCount - dashboard.providedInfoCompleteCount, 0);
const plainCount = Math.max(
totalCount - dashboard.confidentialCount - dashboard.auditDeptCount,
0,
);
return {
color: ['#e11d48', '#f59e0b', '#fb7185', '#10b981'],
tooltip: {
trigger: 'item',
},
series: [
{
type: 'pie',
radius: ['54%', '76%'],
center: ['28%', '48%'],
startAngle: 90,
label: {
show: false,
},
labelLine: {
show: false,
},
data: [
{
name: '提供信息完整',
value: dashboard.providedInfoCompleteCount,
},
{
name: '待补录提供信息',
value: incompleteCount,
},
],
},
{
type: 'pie',
radius: ['20%', '36%'],
center: ['28%', '48%'],
startAngle: 90,
label: {
show: false,
},
labelLine: {
show: false,
},
data: [
{
name: '涉密材料',
value: dashboard.confidentialCount,
itemStyle: {
color: '#fb7185',
},
},
{
name: '审计部材料',
value: dashboard.auditDeptCount,
itemStyle: {
color: '#10b981',
},
},
{
name: '其它材料',
value: plainCount,
itemStyle: {
color: '#e4e4e7',
},
},
],
},
],
graphic: [
{
type: 'text',
left: '20%',
top: '43%',
style: {
text: `${totalCount}`,
fill: '#09090b',
fontSize: 24,
fontWeight: 700,
textAlign: 'center',
},
},
{
type: 'text',
left: '18%',
top: '53%',
style: {
text: '材料总数',
fill: '#71717a',
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
},
},
],
};
}, [
dashboard.auditDeptCount,
dashboard.confidentialCount,
dashboard.providedInfoCompleteCount,
dashboard.totalCount,
]);
const topCategoryCards = useMemo( const topCategoryCards = useMemo(
() => dashboard.categoryGroups.slice(0, 4), () => dashboard.categoryGroups.slice(0, 4),
[dashboard.categoryGroups], [dashboard.categoryGroups],
); );
const providedInfoPercent = useMemo(() => {
if (dashboard.totalCount <= 0) {
return 0;
}
return Math.round((dashboard.providedInfoCompleteCount / dashboard.totalCount) * 100);
}, [dashboard.providedInfoCompleteCount, dashboard.totalCount]);
const missingProvideInfoCount = useMemo(
() => Math.max(dashboard.totalCount - dashboard.providedInfoCompleteCount, 0),
[dashboard.providedInfoCompleteCount, dashboard.totalCount],
);
const plainGovernanceCount = useMemo(
() =>
Math.max(
dashboard.totalCount - dashboard.confidentialCount - dashboard.auditDeptCount,
0,
),
[dashboard.auditDeptCount, dashboard.confidentialCount, dashboard.totalCount],
);
const maxProvideDepartmentCount = useMemo(
() =>
Math.max(
...dashboard.provideDepartmentGroups.slice(0, 5).map((item) => item.count),
1,
),
[dashboard.provideDepartmentGroups],
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Panel className="relative overflow-hidden p-6 sm:p-8"> <Panel className="relative overflow-hidden p-6 sm:p-8">
<div className="absolute inset-x-0 top-0 h-40 bg-[radial-gradient(circle_at_top_left,_rgba(225,29,72,0.18),_transparent_45%),radial-gradient(circle_at_top_right,_rgba(251,113,133,0.14),_transparent_35%)]" /> <div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(255,241,242,0.96)_0%,rgba(255,255,255,0.97)_48%,rgba(255,245,245,0.94)_100%),radial-gradient(circle_at_top_left,rgba(225,29,72,0.16),transparent_38%),radial-gradient(circle_at_top_right,rgba(251,113,133,0.12),transparent_32%)]" />
<div className="relative"> <div className="relative">
<div className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-rose-700"> <div className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-rose-700">
MATERIAL OVERVIEW MATERIAL OVERVIEW
...@@ -439,16 +407,7 @@ export default function ResourceStatPage() { ...@@ -439,16 +407,7 @@ export default function ResourceStatPage() {
</p> </p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 xl:w-[26rem]"> <div className="xl:w-56">
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
总占用
</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{dashboard.totalBytesLabel}
</div>
<div className="mt-1 text-sm text-zinc-500">按当前可见材料估算</div>
</div>
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm"> <div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500"> <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
最近 7 天新增 最近 7 天新增
...@@ -476,7 +435,7 @@ export default function ResourceStatPage() { ...@@ -476,7 +435,7 @@ export default function ResourceStatPage() {
{loading ? ( {loading ? (
<div className="mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700"> <div className="mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700">
正在加载材料总览... 正在加载工作台...
</div> </div>
) : null} ) : null}
</div> </div>
...@@ -497,6 +456,13 @@ export default function ResourceStatPage() { ...@@ -497,6 +456,13 @@ export default function ResourceStatPage() {
hint="跨应用汇入的来源数量" hint="跨应用汇入的来源数量"
tone="green" tone="green"
/> />
<MetricCard
icon={CircleStackIcon}
label="来源系统"
value={dashboard.uniqueSourceSystemCount}
hint="跨系统汇入的来源数量"
tone="blue"
/>
<MetricCard <MetricCard
icon={BuildingOffice2Icon} icon={BuildingOffice2Icon}
label="提供信息完整" label="提供信息完整"
...@@ -541,11 +507,11 @@ export default function ResourceStatPage() { ...@@ -541,11 +507,11 @@ export default function ResourceStatPage() {
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Panel className="p-6"> <Panel className="p-6">
<SectionHeader <SectionHeader
title="来源应用分布" title="来源应用分布"
description="帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。" description="帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。"
/> />
<ChartPanel option={sourceDistributionOption} emptyText="暂无来源分布数据" /> <ChartPanel option={sourceDistributionOption} emptyText="暂无来源分布数据" />
<div className="mt-4 grid gap-3 sm:grid-cols-3"> <div className="mt-4 grid gap-3 sm:grid-cols-3">
{dashboard.sourceGroups.slice(0, 3).map((item, index) => ( {dashboard.sourceGroups.slice(0, 3).map((item, index) => (
<div <div
key={item.key} key={item.key}
...@@ -566,6 +532,26 @@ export default function ResourceStatPage() { ...@@ -566,6 +532,26 @@ export default function ResourceStatPage() {
</div> </div>
))} ))}
</div> </div>
<div className="mt-4 rounded-[20px] border border-zinc-200 bg-zinc-50/80 px-4 py-4">
<div className="text-sm font-medium text-zinc-700">来源系统分布</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{dashboard.sourceSystemGroups.slice(0, 4).length > 0 ? (
dashboard.sourceSystemGroups.slice(0, 4).map((item) => (
<div
key={item.key}
className="flex items-center justify-between gap-3 rounded-2xl border border-zinc-200 bg-white px-3 py-2 text-sm"
>
<span className="min-w-0 truncate text-zinc-700">{item.label}</span>
<span className="shrink-0 tabular-nums text-zinc-500">
{item.count} 份
</span>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无来源系统数据</div>
)}
</div>
</div>
</Panel> </Panel>
<Panel className="p-6"> <Panel className="p-6">
...@@ -609,42 +595,118 @@ export default function ResourceStatPage() { ...@@ -609,42 +595,118 @@ export default function ResourceStatPage() {
title="提供与合规" title="提供与合规"
description="根据最新字段快速看清提供信息补录进度,以及涉密、审计部材料的标记情况。" description="根据最新字段快速看清提供信息补录进度,以及涉密、审计部材料的标记情况。"
/> />
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(20rem,0.85fr)] xl:items-stretch"> <div className="grid gap-4 xl:grid-cols-3">
<ChartPanel <div className="rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-5">
option={governanceOption} <div className="flex items-start justify-between gap-4">
className="h-[320px] sm:h-[340px] xl:h-[360px]" <div>
emptyText="暂无提供与合规数据" <div className="text-sm font-medium text-zinc-500">提供信息补录</div>
/> <div className="mt-3 flex items-end gap-2">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1"> <span className="text-4xl font-semibold tabular-nums text-zinc-950">
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50/80 px-4 py-4"> {providedInfoPercent}%
<div className="text-sm text-zinc-500">已补齐提供信息</div> </span>
<div className="mt-2 text-2xl font-semibold text-zinc-950"> <span className="pb-1 text-sm text-zinc-500">
{dashboard.providedInfoCompleteCount} {dashboard.providedInfoCompleteCount}/{dashboard.totalCount}
</span>
</div>
</div>
<BuildingOffice2Icon className="size-6 text-amber-600" />
</div>
<div className="mt-5">
<ProgressBar value={providedInfoPercent} tone="amber" />
</div>
<div className="mt-5 grid grid-cols-3 gap-3 text-sm">
<div>
<div className="font-semibold tabular-nums text-zinc-950">
{dashboard.providedInfoCompleteCount}
</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">已补齐</div>
</div> </div>
<div className="mt-1 text-xs text-zinc-500"> <div>
缺少部门 {dashboard.missingProvideDepartmentCount},缺少时间{' '} <div className="font-semibold tabular-nums text-zinc-950">
{dashboard.missingProvideTimeCount} {dashboard.missingProvideDepartmentCount}
</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">缺部门</div>
</div>
<div>
<div className="font-semibold tabular-nums text-zinc-950">
{dashboard.missingProvideTimeCount}
</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">缺时间</div>
</div> </div>
</div> </div>
<div className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4"> <div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs leading-5 text-amber-800">
<div className="text-sm font-medium text-zinc-700">主要提供部门</div> 待补录 {missingProvideInfoCount} 份,优先补齐提供部门和提供时间。
<div className="mt-3 flex flex-wrap gap-2"> </div>
{dashboard.provideDepartmentGroups.length > 0 ? ( </div>
dashboard.provideDepartmentGroups.slice(0, 4).map((item) => (
<span <div className="rounded-[24px] border border-zinc-200 bg-white p-5">
key={item.key} <div className="mb-4 flex items-center justify-between gap-3">
className="inline-flex rounded-full border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-700" <div>
> <div className="text-sm font-medium text-zinc-500">合规标记</div>
{item.label} · {item.count} <div className="mt-1 text-xs leading-5 text-zinc-500">
</span> 分别关注敏感材料、审计部材料与常规材料占比。
)) </div>
) : (
<div className="text-sm text-zinc-400">当前还没有已登记的提供部门</div>
)}
</div> </div>
<ShieldCheckIcon className="size-6 text-emerald-600" />
</div>
<div className="space-y-3">
<RatioRow
icon={ShieldExclamationIcon}
label="涉密材料"
value={dashboard.confidentialCount}
total={dashboard.totalCount}
tone="rose"
/>
<RatioRow
icon={ShieldCheckIcon}
label="审计部材料"
value={dashboard.auditDeptCount}
total={dashboard.totalCount}
tone="emerald"
/>
<RatioRow
label="其它材料"
value={plainGovernanceCount}
total={dashboard.totalCount}
tone="sky"
/>
</div> </div>
<div className="rounded-[22px] border border-dashed border-zinc-200 bg-zinc-50/70 px-4 py-4 text-sm text-zinc-600 sm:col-span-2 xl:col-span-1"> </div>
图表外圈表示提供信息补录情况,内圈用于区分涉密材料、审计部材料与其它材料,便于先看合规密度,再判断是否需要回到归档页补录。
<div className="rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-medium text-zinc-500">主要提供部门</div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-zinc-950">
{dashboard.provideDepartmentGroups.length}
</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">已登记部门数</div>
</div>
<CircleStackIcon className="size-6 text-sky-600" />
</div>
<div className="mt-5 space-y-4">
{dashboard.provideDepartmentGroups.length > 0 ? (
dashboard.provideDepartmentGroups.slice(0, 5).map((item) => (
<div key={item.key}>
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0 truncate font-medium text-zinc-800">
{item.label}
</div>
<div className="shrink-0 tabular-nums text-zinc-500">
{item.count} 份
</div>
</div>
<ProgressBar
value={(item.count / maxProvideDepartmentCount) * 100}
tone="sky"
/>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white px-4 py-5 text-sm text-zinc-400">
当前还没有已登记的提供部门
</div>
)}
</div> </div>
</div> </div>
</div> </div>
...@@ -655,78 +717,48 @@ export default function ResourceStatPage() { ...@@ -655,78 +717,48 @@ export default function ResourceStatPage() {
</div> </div>
</Panel> </Panel>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <Panel className="p-6">
<Panel className="p-6"> <SectionHeader
<SectionHeader title="最近上传"
title="最近上传" description="快速浏览最近进入材料池的文件,便于发现异常命名、来源遗漏或归档不完整的情况。"
description="快速浏览最近进入材料池的文件,便于发现异常命名、来源遗漏或归档不完整的情况。" />
/> <div className="space-y-3">
<div className="space-y-3"> {dashboard.recentMaterials.length > 0 ? (
{dashboard.recentMaterials.length > 0 ? ( dashboard.recentMaterials.map((item) => (
dashboard.recentMaterials.map((item) => ( <div
<div key={item.id}
key={item.id} className="flex flex-col gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
className="flex flex-col gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between" >
> <div className="min-w-0">
<div className="min-w-0"> <div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="truncate text-sm font-semibold text-zinc-950 sm:text-base">
<div className="truncate text-sm font-semibold text-zinc-950 sm:text-base">
{item.materialTitle}
</div>
<TagPill>{item.archiveCategory}</TagPill>
{item.tag ? <TagPill>{item.tag}</TagPill> : null}
</div>
<div className="mt-2 text-sm text-zinc-500">
{item.sourceAppLabel} · {item.uploaderLabel} · {item.fileSizeLabel}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{item.isConfidential ? <TagPill tone="amber">涉密</TagPill> : null}
{item.isAuditDeptMaterial ? (
<TagPill tone="emerald">审计部材料</TagPill>
) : null}
{item.provideDepartment ? (
<TagPill>{item.provideDepartment}</TagPill>
) : null}
{item.tag ? <TagPill>{item.tag}</TagPill> : null}
</div>
</div>
<div className="text-sm text-zinc-500">{formatDateTime(item.uploadedAt)}</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无最近上传材料</div>
)}
</div>
</Panel>
<div className="space-y-6">
<Panel className="p-6">
<SectionHeader
title="待完善清单"
description="优先处理缺少来源、分类、标签或提供信息的材料,后续检索和追溯会更稳定。"
/>
<div className="space-y-3">
{dashboard.attentionMaterials.length > 0 ? (
dashboard.attentionMaterials.map((item) => (
<div
key={item.id}
className="rounded-[22px] border border-amber-200 bg-amber-50 px-4 py-4"
>
<div className="text-sm font-semibold text-zinc-900">
{item.materialTitle} {item.materialTitle}
</div> </div>
<div className="mt-2 text-xs leading-6 text-amber-800"> <TagPill>{item.archiveCategory}</TagPill>
{item.attentionReasons.join(' · ')} {item.tag ? <TagPill>{item.tag}</TagPill> : null}
</div>
</div> </div>
)) <div className="mt-2 text-sm text-zinc-500">
) : ( {item.sourceAppLabel} · {item.sourceSystemLabel} · {item.uploaderLabel} · {item.fileSizeLabel}
<div className="text-sm text-zinc-400">当前没有待完善材料</div> </div>
)} <div className="mt-2 flex flex-wrap gap-2">
</div> {item.isConfidential ? <TagPill tone="amber">涉密</TagPill> : null}
</Panel> {item.isAuditDeptMaterial ? (
<TagPill tone="emerald">审计部材料</TagPill>
) : null}
{item.provideDepartment ? (
<TagPill>{item.provideDepartment}</TagPill>
) : null}
{item.tag ? <TagPill>{item.tag}</TagPill> : null}
</div>
</div>
<div className="text-sm text-zinc-500">{formatDateTime(item.uploadedAt)}</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无最近上传材料</div>
)}
</div> </div>
</div> </Panel>
{!loading && materials.length === 0 ? ( {!loading && materials.length === 0 ? (
<Panel className="p-8 text-center text-sm text-zinc-500"> <Panel className="p-8 text-center text-sm text-zinc-500">
......
...@@ -173,6 +173,7 @@ ...@@ -173,6 +173,7 @@
"url": "https://oss.example.com/audit/xxx.pdf" "url": "https://oss.example.com/audit/xxx.pdf"
}, },
"source_app": "中台", "source_app": "中台",
"source_system": "middle_platform",
"archive_category": "试试水", "archive_category": "试试水",
"uploader": "69b12a47e92b1de77c94a9f5", "uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-05-09T08:30:00.000Z", "uploaded_at": "2026-05-09T08:30:00.000Z",
...@@ -192,6 +193,7 @@ ...@@ -192,6 +193,7 @@
"page_size": 20, "page_size": 20,
"filter": { "filter": {
"metadata.source_app": "project_manager_xy", "metadata.source_app": "project_manager_xy",
"metadata.source_system": "project_manager_xy",
"metadata.archive_category": "审计证据", "metadata.archive_category": "审计证据",
"metadata.tag": "项目一期" "metadata.tag": "项目一期"
}, },
...@@ -220,6 +222,7 @@ ...@@ -220,6 +222,7 @@
"url": "https://oss.streams7.com/xinyuan/3WCqMeKoiqOc.xlsx" "url": "https://oss.streams7.com/xinyuan/3WCqMeKoiqOc.xlsx"
}, },
"source_app": "中台", "source_app": "中台",
"source_system": "middle_platform",
"archive_category": "试试水", "archive_category": "试试水",
"uploader": "64b6575b80bdbd9b3d65d09b", "uploader": "64b6575b80bdbd9b3d65d09b",
"uploaded_at": "2026-05-08T16:00:00.000Z", "uploaded_at": "2026-05-08T16:00:00.000Z",
...@@ -234,4 +237,4 @@ ...@@ -234,4 +237,4 @@
- `title`、`content_type`、`metadata`、`subject` 这一层结构正确 - `title`、`content_type`、`metadata`、`subject` 这一层结构正确
- `file_asset` 的 `name/type/size/url` 结构正确 - `file_asset` 的 `name/type/size/url` 结构正确
- `uploader` 直接传用户 `_id` 字符串正确 - `uploader` 直接传用户 `_id` 字符串正确
- `source_app` 和 `archive_category` 现在已按你的要求改成非必填 - `source_app`、`source_system` 和 `archive_category` 现在已按你的要求改成非必填
...@@ -101,6 +101,7 @@ type Record = { ...@@ -101,6 +101,7 @@ type Record = {
| 材料标题 | `material_title` | text | 否 | 否 | 是 | 业务展示主标题 | | 材料标题 | `material_title` | text | 否 | 否 | 是 | 业务展示主标题 |
| 文件对象 | `file_asset` | file | 否 | 否 | 是 | 上传接口返回的文件对象 | | 文件对象 | `file_asset` | file | 否 | 否 | 是 | 上传接口返回的文件对象 |
| 来源应用 | `source_app` | text | 否 | 否 | 否 | 材料最初产生于哪个应用 | | 来源应用 | `source_app` | text | 否 | 否 | 否 | 材料最初产生于哪个应用 |
| 来源系统 | `source_system` | text | 否 | 否 | 否 | 材料最初产生于哪个系统 |
| 材料分类 | `archive_category` | text | 否 | 否 | 否 | 用户自定义分类 | | 材料分类 | `archive_category` | text | 否 | 否 | 否 | 用户自定义分类 |
| 上传人 | `uploader` | user | 否 | 否 | 是 | 当前登录用户 | | 上传人 | `uploader` | user | 否 | 否 | 是 | 当前登录用户 |
| 上传时间 | `uploaded_at` | date | 否 | 否 | 是 | 创建主档时间 | | 上传时间 | `uploaded_at` | date | 否 | 否 | 是 | 创建主档时间 |
...@@ -123,6 +124,7 @@ type Record = { ...@@ -123,6 +124,7 @@ type Record = {
"url": "https://oss.example.com/audit/meeting-note.pdf" "url": "https://oss.example.com/audit/meeting-note.pdf"
}, },
"source_app": "project_manager_xy", "source_app": "project_manager_xy",
"source_system": "project_manager_xy",
"archive_category": "审计证据", "archive_category": "审计证据",
"uploader": "69b12a47e92b1de77c94a9f5", "uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-05-09T08:30:00.000Z", "uploaded_at": "2026-05-09T08:30:00.000Z",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment