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 = {
export default defineConfig({
layout: false,
fastRefresh: true,
base: '/audit_material_manager/',
publicPath: '/audit_material_manager/',
alias: {
react: resolvePackageRoot('react'),
'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 xyLogoSmall from '@/assets/xy-logo_small.png';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import {
ArchiveBoxIcon,
Bars3Icon,
......@@ -18,7 +20,7 @@ const menuGroups = [
title: '统一材料工作台',
items: [
{
name: '材料总览',
name: '工作台',
path: '/resource/stat',
icon: Squares2X2Icon,
},
......@@ -174,10 +176,12 @@ function MobileSidebar({ open, close, children }) {
export default function ProjectManagerLayout() {
const location = useLocation();
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const userInfo = useContext(UserContext);
useEffect(() => {
if (typeof window === 'undefined') {
......@@ -219,6 +223,21 @@ export default function ProjectManagerLayout() {
);
}, [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 (
<div className="flex h-screen overflow-hidden bg-zinc-50">
<aside
......@@ -229,22 +248,34 @@ export default function ProjectManagerLayout() {
>
<div
className={clsx(
'mb-7 flex items-center',
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1',
'mb-7',
sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)}
>
{sidebarCollapsed ? (
<>
<img
src={xyLogoSmall}
alt="XY Logo"
className="h-9 w-auto max-w-[52px] shrink-0 object-contain"
className="h-8 w-auto max-w-8 shrink-0 object-contain"
/>
) : null}
{!sidebarCollapsed ? (
{applicationSwitcherButton}
</>
) : (
<>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME}
</h1>
) : null}
<div className="mt-1 text-xs text-zinc-400">
统一审计材料上传与归档中心
</div>
</div>
{applicationSwitcherButton}
</>
)}
</div>
<nav className="flex-1 space-y-5 overflow-y-auto">
......@@ -270,6 +301,17 @@ export default function ProjectManagerLayout() {
</div>
))}
</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>
<div className="relative z-30 hidden lg:flex">
......@@ -341,12 +383,26 @@ export default function ProjectManagerLayout() {
<Bars3Icon className="size-5" />
</NavbarItem>
<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 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 />
</div>
</main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div>
);
}
......@@ -21,13 +21,14 @@
建议保留现有两级菜单框架,但替换业务语义:
- 左侧菜单:`材料总览`
- 左侧菜单:`工作台`
- 左侧菜单:`材料归档`
- 左侧菜单顶部:`应用切换` 入口,打开应用切换弹框
推荐职责划分:
- `材料总览`
- 展示材料总数、分类分布、来源应用分布、最近上传
- `工作台`
- 展示材料总数、分类分布、来源应用/来源系统分布、最近上传
- 提供全局检索入口
- `材料归档`
- 上传材料
......@@ -35,6 +36,12 @@
- 查看材料列表与详情
- 支持“全部文件”列表视图与按分类切换浏览
- 材料详情使用弹框查看与编辑
- `应用切换`
- 桌面端入口位于左侧菜单栏顶部;菜单折叠时保留九宫格图标按钮
- 移动端入口位于顶部导航栏右侧
- 打开弹框后调用 `/application/info` 获取当前账号可访问应用,并与登录用户信息中的 `applications` 合并展示
- 应用列表加载失败时,回退展示当前账号上的应用信息,并显示失败提示
- 点击应用卡片后进入对应应用工作区
---
......@@ -49,6 +56,7 @@
- `material_title`:材料标题
- `file_asset`:文件对象
- `source_app`:来源应用,可选
- `source_system`:来源系统,可选
- `archive_category`:分类,可选
- `uploader` / `uploaded_at`:上传人、上传时间
- `remark`:备注,可选
......@@ -67,47 +75,60 @@
2. 选择本地文件;若选择的是 `ZIP` 压缩包,可进一步选择上传模式:
- `整包上传`:将压缩包本身作为 1 份材料处理
- `解压后逐文件上传`:按压缩包内文件逐个建档
3. 用户补充标题、标签、分类、提供部门、提供时间、是否涉密、是否审计部材料等信息
3. 用户补充标题、来源应用、来源系统、标签、分类、提供部门、提供时间、是否涉密、是否审计部材料等信息
4. 先调用上传接口上传二进制文件
5. 上传成功后,得到 `{name, type, size, url}` 文件对象
6. 再创建 `am_material` 记录:
- 普通文件或 `整包上传`:创建 1 条记录
- `解压后逐文件上传`:压缩包内有多少个文件,就创建多少条记录
7. 当使用 `解压后逐文件上传` 时,上传表单中的来源应用、分类、标签、提供信息、合规属性等字段作为每个文件的通用属性一并写入
7. 当使用 `解压后逐文件上传` 时,上传表单中的来源应用、来源系统、分类、标签、提供信息、合规属性等字段作为每个文件的通用属性一并写入
8. 材料进入统一材料池,可被其他审计应用统一查看
### 4.2 在其他业务应用上传材料
1. 用户在 `审计项目管理` 或 `问题整改` 等业务页面上传附件
2. 前端或中台先上传文件,再创建 `am_material`
3. 如有需要,可在 `source_app` 中标记材料来源应用
4. 材料中心自动能查到这份材料,并能按来源应用统一管理
3. 如有需要,可在 `source_app` 中标记材料来源应用,在 `source_system` 中标记材料来源系统
4. 材料中心自动能查到这份材料,并能按来源应用或来源系统统一管理
### 4.3 在材料中心统一查看与管理
1. 用户进入 `材料归档`
2. 按关键词、标签、来源应用筛选材料
3. 页面提供“全部文件”与“分类切换”两种浏览方式
2. 在“搜索材料”区域按字段手填筛选条件,支持模糊搜索:
- 材料标题:匹配 `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 条
5. 用户通过上一页 / 下一页进行分页浏览
6. 在“全部文件”视图下,直接以列表展示当前筛选条件下的全部材料
7. 切换到某个分类标签后,只查看该分类下的材料
7. 切换到某个分类标签后,系统将分类名称回填到“材料分类”搜索字段,并重新从第一页加载模糊匹配结果
8. `未分类` 仅表示 `metadata.archive_category` 为空、空字符串或缺失,不作为真实分类值写回查询条件
9. 列表支持当前页批量勾选,并提供“全选当前页”能力
10. 勾选后可执行以下批量操作:
- 批量修改:按字段选择是否覆盖来源应用、分类、提供部门、提供时间、是否涉密、是否审计部材料
- 批量下载/导出:勾选 1 份材料时直接下载原文件;勾选多份材料时,前端读取各材料 `metadata.file_asset.url` 并打包为 `ZIP` 压缩包下载
- 批量修改:按字段选择是否覆盖来源应用、来源系统、分类、提供部门、提供时间、是否涉密、是否审计部材料
- 批量打标签:统一覆盖 `metadata.tag`
- 批量删除:对已勾选材料执行软删除
11. 点击某份材料后,以弹框方式打开详情
12. 弹框内查看:
- 文件预览或下载入口
12. 弹框默认进入查看态,字段不可直接编辑,可查看:
- 文件信息与打开或下载入口
- 标签与分类
- 来源信息
- 提供部门、提供时间
- 涉密标记、审计部材料标记
13. 管理员或有权限用户可在弹框中修改分类、标签
14. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框
15. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排
13. 用户点击“编辑”后进入编辑态,可修改材料标题、来源应用、来源系统、分类、标签、提供信息与合规属性
14. 编辑态下点击“取消编辑”放弃未保存修改并恢复当前材料原值
15. 编辑态下点击“保存修改”后调用材料更新接口,保存成功后生效并回到查看态
16. 详情弹框提供单个删除入口,删除前需二次确认,确认后对当前材料执行软删除并关闭弹框
17. 详情弹框中的编辑区域按“基础信息 / 提供信息 / 合规属性”三段式维护,减少字段混排
### 4.4 删除或归档材料
......@@ -131,36 +152,41 @@
- 直接查询 `am_material`
- 需要按来源区分时:
- 使用 `metadata.source_app` 过滤
- 使用 `metadata.source_system` 过滤
### 5.3 材料中心如何看到其他应用上传的材料
- 只要其他应用上传时同步创建 `am_material`
- 并补充 `source_app`
- 并补充 `source_app` 与 `source_system`
- 材料中心即可统一查询、展示与管理
---
## 6. 页面与交互建议
### 6.1 材料总览
### 6.1 工作台
建议至少展示:
- 材料总数
- 本周新增材料数
- 来源应用分布
- 来源系统分布
- 分类分布
- 提供信息完整度
- 涉密材料 / 审计部材料概览
- 最近上传材料
- 待完善材料提醒
当前工作台推荐采用“图表 + 风险清单”布局:
当前工作台推荐采用“图表 + 最近上传列表”布局:
- 来源应用分布使用柱状图,便于快速判断材料是否已从各业务应用顺利汇入
- 来源系统分布展示各系统汇入数量,便于判断跨系统同步是否完整
- 分类分布使用环形图,便于观察常用分类与未分类占比
- 提供与合规信息使用双层环图或类似图形,突出补录完成度、涉密材料、审计部材料情况
- 最近上传与待完善材料以列表方式承接,方便后续直接进入归档页处理
- 提供与合规信息拆分为补录进度、合规标记、主要提供部门三块,避免把提供完整度、涉密状态和审计部材料状态集中在同一个图表中
- 补录进度展示提供信息完整率、已补齐数量、缺少提供部门数量、缺少提供时间数量,并提示待补录材料数
- 合规标记使用独立进度条分别展示涉密材料、审计部材料和其它材料占比,便于逐项判断风险密度
- 主要提供部门使用排行进度条展示已登记部门分布,方便识别材料提供来源是否集中
- 最近上传材料以列表方式承接,方便快速发现异常命名、来源遗漏或归档不完整的情况
### 6.2 材料归档
......@@ -169,9 +195,11 @@
- 上传按钮
- 标签筛选
- 来源应用筛选
- 来源系统筛选
- 分类切换标签页
- 材料列表表格
- 当前页批量勾选工具条
- 批量下载/导出按钮
- 材料详情弹框
- 批量修改弹框
- 批量打标签弹框
......@@ -180,7 +208,18 @@
- 提供信息区
- 合规属性区
### 6.3 材料详情
### 6.3 应用切换弹框
建议至少包含:
- 菜单栏九宫格应用切换入口
- 当前账号可见应用数量
- 按站点配置分组展示应用;没有分组时展示“全部应用”
- 当前应用标记
- 接口加载失败提示与账号应用信息兜底
- 无可切换应用时的空状态
### 6.4 材料详情
建议展示:
......@@ -189,10 +228,13 @@
- 标签
- 分类
- 来源应用
- 来源系统
- 提供部门 / 提供时间
- 是否涉密 / 是否审计部材料
- 上传人 / 上传时间
- 下载或预览入口
- 打开或下载入口
- 默认只读查看,点击“编辑”后进入编辑态
- 编辑态支持“取消编辑”恢复当前材料原值,或“保存修改”提交后回到查看态
---
......@@ -214,13 +256,29 @@
- `POST /record/query`
- `subject = am_material`
- 字段搜索采用展开式表单,不再使用聚合关键词搜索
- 可使用以下过滤条件:
- `metadata.material_title`
- `metadata.file_asset.name`
- `metadata.source_app`
- `metadata.source_system`
- `metadata.archive_category`
- `metadata.tag`
- `metadata.provide_department`
- `metadata.remark`
- `metadata.is_confidential`
- `metadata.is_audit_dept_material`
- 文本字段按 `$regex` + `$options: i` 组织为模糊匹配
- 布尔字段采用“全部 / 是 / 否”三态筛选,选择“全部”时不进入查询条件
- 多个搜索字段同时填写时按 AND 关系组合
### 7.3 批量管理材料
- 批量下载/导出:
- 前端基于当前页勾选结果读取材料记录中的 `metadata.file_asset`
- 勾选 1 个文件时,使用 `metadata.file_asset.url` 直接触发浏览器下载
- 勾选多个文件时,前端按 `metadata.file_asset.name` 拉取文件内容并生成 ZIP 压缩包
- 若部分材料缺少 `metadata.file_asset.url` 或文件地址不可访问,则跳过该材料并在操作结果中提示跳过数量
- 批量修改:
- 前端先基于当前页勾选结果拿到材料记录
- 再按勾选字段逐条调用 `PUT /record/:record`
......@@ -240,7 +298,7 @@
- 当材料数量超过 1000 份时,不应一次性全量拉取
- 归档页首屏只加载一页数据,默认每页 20 条,可切换为 50 或 100 条
- 用户通过分页按钮切换页码,不再使用滚动加载
- 当用户切换分类、关键词、来源或页大小时,按当前条件重新从第一页加载
- 当用户调整任一搜索字段、点击分类快捷标签或切换页大小时,按当前条件重新从第一页加载
- “全部文件”视图按页展示结果
- 总数统计通过 `count` 接口单独获取,不依赖前端已加载条数推断
......@@ -253,6 +311,7 @@
- ZIP 拆分上传若中途失败,需要明确提示已成功创建的记录条数以及失败文件名,便于用户补传
- 标签允许自由输入,但需控制重复标签与空标签
- 分类和标签用于支持用户自定义分组
- 批量下载/导出依赖 `metadata.file_asset.url` 可被当前浏览器访问;若跨域或文件失效导致拉取失败,多文件 ZIP 中会跳过失败项并提示用户
- 批量修改仅允许覆盖用户显式勾选的字段,未勾选字段必须保留原值,避免误覆盖
- 批量删除前必须二次确认,默认仍按软删除处理
- 当前版本的批量勾选范围限定在“当前筛选结果的当前页”,切换分页后需要重新确认勾选结果
......
......@@ -24,6 +24,14 @@ const SOURCE_APP_LABEL_MAP = {
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 = [
{ value: 'audit_material_center', label: '材料中心' },
{ value: 'project_manager_xy', label: '审计项目管理' },
......@@ -32,7 +40,70 @@ export const SOURCE_APP_SUGGESTIONS = [
{ 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 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 = [
{
......@@ -51,6 +122,7 @@ const DEMO_MATERIALS = [
url: 'https://example.com/materials/demo-1.pdf',
},
source_app: 'project_manager_xy',
source_system: 'project_manager_xy',
archive_category: '审计证据',
uploader: {
_id: '69b12a47e92b1de77c94a9f5',
......@@ -76,6 +148,7 @@ const DEMO_MATERIALS = [
url: 'https://example.com/materials/demo-2.docx',
},
source_app: 'audit_material_center',
source_system: 'audit_material_manage',
archive_category: '项目资料',
uploader: {
_id: '69b12a47e92b1de77c94a9f5',
......@@ -101,6 +174,7 @@ const DEMO_MATERIALS = [
url: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?auto=format&fit=crop&w=900&q=80',
},
source_app: 'issue_rectification',
source_system: 'project_manager_xy',
archive_category: '整改资料',
uploader: {
_id: '69b12a47e92b1de77c94a9f6',
......@@ -126,6 +200,7 @@ const DEMO_MATERIALS = [
url: 'https://example.com/materials/demo-4.pdf',
},
source_app: 'audit_material_center',
source_system: 'audit_material_manage',
archive_category: '项目资料',
uploader: {
_id: '69b12a47e92b1de77c94a9f7',
......@@ -151,6 +226,7 @@ const DEMO_MATERIALS = [
url: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=900&q=80',
},
source_app: 'project_manager_xy',
source_system: 'project_manager_xy',
archive_category: '审计证据',
uploader: {
_id: '69b12a47e92b1de77c94a9f8',
......@@ -265,6 +341,14 @@ export function getSourceAppLabel(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 = {}) {
const metadata = record?.metadata || {};
const fileAsset = metadata.file_asset || {};
......@@ -272,6 +356,7 @@ export function normalizeMaterialRecord(record = {}) {
const materialTitle = metadata.material_title || record.title || '未命名材料';
const uploadedAt = metadata.uploaded_at || record.created_at || '';
const sourceApp = metadata.source_app || '';
const sourceSystem = metadata.source_system || '';
const category = metadata.archive_category || '';
const tag = metadata.tag || '';
const provideTime = metadata.provide_time || '';
......@@ -289,6 +374,8 @@ export function normalizeMaterialRecord(record = {}) {
fileUrl: fileAsset.url || '',
sourceApp,
sourceAppLabel: getSourceAppLabel(sourceApp),
sourceSystem,
sourceSystemLabel: getSourceSystemLabel(sourceSystem),
archiveCategory: category || UNCATEGORIZED_CATEGORY_LABEL,
tag,
remark: metadata.remark || '',
......@@ -314,35 +401,76 @@ function isUncategorizedCategory(category = '') {
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 = {}) {
const keyword = String(filters.keyword || '').trim().toLowerCase();
const sourceApp = String(filters.sourceApp || '').trim();
const archiveCategory = String(filters.archiveCategory || '').trim();
const tag = String(filters.tag || '').trim().toLowerCase();
const archiveCategory = normalizeFilterValue(filters.archiveCategory);
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 =
!archiveCategory ||
(isUncategorizedCategory(archiveCategory)
? !String(item.rawMetadata?.archive_category || '').trim()
: item.archiveCategory === archiveCategory);
const matchesTag =
!tag || String(item.tag || '').toLowerCase().includes(tag);
: true);
const matchesSearchFields = MATERIAL_SEARCH_FIELDS.every(
({ 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) {
}
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) {
filter['metadata.source_app'] = filters.sourceApp;
MATERIAL_BOOLEAN_SEARCH_FIELDS.forEach(({ filterKey, recordPath }) => {
const value = parseBooleanFilter(filters[filterKey]);
if (value === undefined) {
return;
}
if (filters.archiveCategory) {
filter['$or'] = isUncategorizedCategory(filters.archiveCategory)
? [
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': filters.archiveCategory }];
],
}
: { 'metadata.archive_category': buildFuzzyFilter(archiveCategory) },
);
}
if (filters.tag) {
filter['metadata.tag'] = filters.tag;
if (conditions.length === 0) {
return {};
}
return filter;
if (conditions.length === 1) {
return conditions[0];
}
return {
$and: conditions,
};
}
function hasActiveFilters(filters = {}) {
return Boolean(
String(filters.keyword || '').trim() ||
String(filters.sourceApp || '').trim() ||
String(filters.archiveCategory || '').trim() ||
String(filters.tag || '').trim(),
return (
MATERIAL_SEARCH_FIELDS.some(({ filterKey }) =>
Boolean(normalizeFilterValue(filters[filterKey])),
) ||
MATERIAL_BOOLEAN_SEARCH_FIELDS.some(
({ filterKey }) => parseBooleanFilter(filters[filterKey]) !== undefined,
)
);
}
......@@ -448,6 +612,11 @@ export function buildMaterialDashboard(materials = [], options = {}) {
(item) => item.sourceApp || 'unknown',
(item) => item.sourceAppLabel,
);
const sourceSystemGroups = buildGroup(
materials,
(item) => item.sourceSystem || 'unknown',
(item) => item.sourceSystemLabel,
);
const categoryGroups = buildGroup(
materials,
(item) => item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL,
......@@ -489,6 +658,9 @@ export function buildMaterialDashboard(materials = [], options = {}) {
if (!item.sourceApp) {
attentionReasons.push('缺少来源应用');
}
if (!item.sourceSystem) {
attentionReasons.push('缺少来源系统');
}
if (!item.rawMetadata?.archive_category) {
attentionReasons.push('缺少材料分类');
}
......@@ -513,6 +685,9 @@ export function buildMaterialDashboard(materials = [], options = {}) {
const uniqueSourceCount = new Set(
materials.map((item) => item.sourceApp).filter(Boolean),
).size;
const uniqueSourceSystemCount = new Set(
materials.map((item) => item.sourceSystem).filter(Boolean),
).size;
const uniqueCategoryCount = new Set(
materials.map((item) => item.archiveCategory).filter(Boolean),
).size;
......@@ -523,6 +698,7 @@ export function buildMaterialDashboard(materials = [], options = {}) {
totalBytesLabel: formatFileSize(totalBytes),
recentCount,
uniqueSourceCount,
uniqueSourceSystemCount,
uniqueCategoryCount,
confidentialCount,
auditDeptCount,
......@@ -530,6 +706,7 @@ export function buildMaterialDashboard(materials = [], options = {}) {
missingProvideDepartmentCount,
missingProvideTimeCount,
sourceGroups,
sourceSystemGroups,
categoryGroups,
tagGroups,
provideDepartmentGroups,
......@@ -544,6 +721,15 @@ export function buildMaterialDashboard(materials = [], options = {}) {
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),
tagSuggestions: tagGroups.map((item) => item.label),
};
......@@ -556,7 +742,6 @@ async function queryMaterialRecords(filters = {}) {
subject: MATERIAL_SUBJECT,
page: filters.page || 0,
page_size: filters.pageSize || 80,
keyword: String(filters.keyword || '').trim() || undefined,
filter,
sort: '-updated_at -created_at',
});
......@@ -568,7 +753,6 @@ export async function loadMaterialPage(filters = {}) {
const page = Number(filters.page || 0);
const pageSize = Number(filters.pageSize || 36);
const filter = buildRecordFilter(filters);
const keyword = String(filters.keyword || '').trim() || undefined;
try {
const [materials, totalResponse] = await Promise.all([
......@@ -579,7 +763,6 @@ export async function loadMaterialPage(filters = {}) {
}),
countRecords({
subject: MATERIAL_SUBJECT,
keyword,
filter,
}),
]);
......@@ -774,6 +957,7 @@ function buildMaterialPayload({
metadata: {
material_title: materialTitle,
source_app: String(values.sourceApp || '').trim(),
source_system: String(values.sourceSystem || '').trim(),
archive_category: String(values.archiveCategory || '').trim(),
uploader:
currentUser?._id || currentUser?.username || currentUser?.id || '',
......@@ -892,6 +1076,7 @@ export async function updateMaterialRecord(material, values) {
material_title: nextTitle,
file_asset: material.fileAsset,
source_app: String(values.sourceApp || '').trim(),
source_system: String(values.sourceSystem || '').trim(),
archive_category: String(values.archiveCategory || '').trim(),
uploader:
material.rawMetadata?.uploader ||
......@@ -980,6 +1165,127 @@ export async function batchRemoveMaterialRecords(materials = []) {
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) {
const recordName = material?.recordName || material?.id;
......
......@@ -26,6 +26,7 @@ import {
ArrowUpTrayIcon,
ArrowTopRightOnSquareIcon,
ClipboardDocumentIcon,
ArrowDownTrayIcon,
FolderIcon,
MagnifyingGlassIcon,
SparklesIcon,
......@@ -44,6 +45,7 @@ import {
import EmptyStatePage from '../EmptyStatePage';
import {
ARCHIVE_UPLOAD_MODE,
batchExportMaterialFiles,
batchRemoveMaterialRecords,
batchUpdateMaterialRecords,
buildMaterialDashboard,
......@@ -56,6 +58,7 @@ import {
removeMaterialRecord,
UNCATEGORIZED_CATEGORY_LABEL,
SOURCE_APP_SUGGESTIONS,
SOURCE_SYSTEM_SUGGESTIONS,
updateMaterialRecord,
} from '../materialService';
......@@ -63,6 +66,21 @@ const ALL_CATEGORY_KEY = '__all__';
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
function buildEmptyMaterialSearchForm() {
return {
materialTitle: '',
fileName: '',
archiveCategory: '',
tag: '',
sourceApp: '',
sourceSystem: '',
provideDepartment: '',
remark: '',
isConfidential: '',
isAuditDeptMaterial: '',
};
}
function Panel({ className, children }) {
return (
<section
......@@ -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 }) {
return (
<TableRow
......@@ -206,6 +180,7 @@ function MaterialListRow({ material, onOpen, checked, onToggle }) {
</div>
</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">{formatDateTime(material.uploadedAt)}</TableCell>
</TableRow>
......@@ -216,6 +191,7 @@ function buildEmptyUploadForm() {
return {
materialTitle: '',
sourceApp: '',
sourceSystem: '',
archiveCategory: '',
tag: '',
provideDepartment: '',
......@@ -229,6 +205,7 @@ function buildEmptyUploadForm() {
function buildEmptyBatchEditForm() {
return {
sourceApp: '',
sourceSystem: '',
archiveCategory: '',
provideDepartment: '',
provideTime: '',
......@@ -240,6 +217,7 @@ function buildEmptyBatchEditForm() {
function buildEmptyBatchFieldSelection() {
return {
sourceApp: false,
sourceSystem: false,
archiveCategory: false,
provideDepartment: false,
provideTime: false,
......@@ -276,6 +254,8 @@ function buildDetailForm(material) {
return {
materialTitle: material.materialTitle || '',
sourceApp: material.rawMetadata?.source_app || material.sourceApp || '',
sourceSystem:
material.rawMetadata?.source_system || material.sourceSystem || '',
archiveCategory:
material.rawMetadata?.archive_category || material.archiveCategory || '',
tag: material.rawMetadata?.tag || material.tag || '',
......@@ -345,10 +325,10 @@ function mergeCategoryOptions(previousOptions, nextMaterials) {
export default function ResourceBookingPage() {
const userInfo = useContext(UserContext);
const fileInputRef = useRef(null);
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const [sourceAppFilter, setSourceAppFilter] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [materialSearchForm, setMaterialSearchForm] = useState(
buildEmptyMaterialSearchForm,
);
const deferredMaterialSearchForm = useDeferredValue(materialSearchForm);
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(0);
......@@ -360,11 +340,13 @@ export default function ResourceBookingPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [isDemoMode, setIsDemoMode] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isDetailEditing, setIsDetailEditing] = useState(false);
const [isBatchEditOpen, setIsBatchEditOpen] = useState(false);
const [isBatchTagOpen, setIsBatchTagOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
......@@ -384,6 +366,7 @@ export default function ResourceBookingPage() {
useEffect(() => {
setDetailForm(buildDetailForm(activeMaterial));
setIsDetailEditing(false);
}, [activeMaterial]);
useEffect(() => {
......@@ -392,11 +375,7 @@ export default function ResourceBookingPage() {
async function runFirstPage() {
setLoading(true);
const result = await loadMaterialPage({
keyword: deferredQuery,
sourceApp: sourceAppFilter,
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
...deferredMaterialSearchForm,
page,
pageSize,
});
......@@ -434,7 +413,7 @@ export default function ResourceBookingPage() {
return () => {
cancelled = true;
};
}, [activeCategory, activeMaterialId, deferredQuery, page, pageSize, sourceAppFilter, tagFilter]);
}, [activeMaterialId, deferredMaterialSearchForm, page, pageSize]);
const sourceOptions = useMemo(() => {
const dynamicOptions = (dashboard.sourceSuggestions || []).filter(Boolean);
......@@ -449,6 +428,19 @@ export default function ResourceBookingPage() {
[],
);
}, [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 archiveCategorySuggestions = dashboard.categorySuggestions || [];
......@@ -475,7 +467,7 @@ export default function ResourceBookingPage() {
activeCategory !== ALL_CATEGORY_KEY &&
!categoryTabs.some((item) => item.key === activeCategory)
) {
setActiveCategory(ALL_CATEGORY_KEY);
handleCategoryTabClick(ALL_CATEGORY_KEY);
}
}, [activeCategory, categoryTabs]);
......@@ -485,16 +477,7 @@ export default function ResourceBookingPage() {
);
}, [materials]);
const visibleMaterials = useMemo(() => {
if (activeCategory === ALL_CATEGORY_KEY) {
return materials;
}
return materials.filter(
(item) =>
(item.archiveCategory || UNCATEGORIZED_CATEGORY_LABEL) === activeCategory,
);
}, [activeCategory, materials]);
const visibleMaterials = materials;
const selectedMaterials = useMemo(
() => visibleMaterials.filter((item) => selectedMaterialIds.includes(item.id)),
......@@ -518,13 +501,33 @@ export default function ResourceBookingPage() {
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 = {}) {
const result = await loadMaterialPage({
keyword: deferredQuery,
sourceApp: sourceAppFilter,
archiveCategory:
activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
...deferredMaterialSearchForm,
page: options.page ?? page,
pageSize,
});
......@@ -595,10 +598,38 @@ export default function ResourceBookingPage() {
function openMaterialDetail(material) {
setActiveMaterialId(material.id);
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('');
setNotice('');
}
function handleCancelDetailEdit() {
setDetailForm(buildDetailForm(activeMaterial));
setIsDetailEditing(false);
setError('');
}
async function handleUploadSubmit() {
if (!selectedFile) {
setNotice('');
......@@ -676,6 +707,7 @@ export default function ResourceBookingPage() {
setNotice('材料信息已更新。');
await reloadMaterials();
setActiveMaterialId(updated.id || activeMaterial.id);
setIsDetailEditing(false);
} catch (saveError) {
setError(saveError?.message || '材料信息保存失败,请稍后重试。');
} finally {
......@@ -730,6 +762,7 @@ export default function ResourceBookingPage() {
prev.filter((id) => id !== activeMaterial.id),
);
setIsDetailOpen(false);
setIsDetailEditing(false);
setActiveMaterialId('');
await reloadMaterials({
page: visibleMaterials.length === 1 && page > 0 ? page - 1 : page,
......@@ -763,6 +796,9 @@ export default function ResourceBookingPage() {
sourceApp: batchEditFields.sourceApp
? batchEditForm.sourceApp
: material.rawMetadata?.source_app || material.sourceApp || '',
sourceSystem: batchEditFields.sourceSystem
? batchEditForm.sourceSystem
: material.rawMetadata?.source_system || material.sourceSystem || '',
archiveCategory: batchEditFields.archiveCategory
? batchEditForm.archiveCategory
: material.rawMetadata?.archive_category ||
......@@ -818,6 +854,8 @@ export default function ResourceBookingPage() {
await batchUpdateMaterialRecords(selectedMaterials, (material) => ({
materialTitle: material.materialTitle,
sourceApp: material.rawMetadata?.source_app || material.sourceApp || '',
sourceSystem:
material.rawMetadata?.source_system || material.sourceSystem || '',
archiveCategory:
material.rawMetadata?.archive_category || material.archiveCategory || '',
tag: String(batchTagForm.tag || '').trim(),
......@@ -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() {
if (selectedMaterials.length === 0) {
setError('请先勾选需要删除的材料。');
......@@ -913,12 +979,7 @@ export default function ResourceBookingPage() {
<Button
outline
className="cursor-pointer"
onClick={() => {
setQuery('');
setSourceAppFilter('');
setTagFilter('');
setActiveCategory(ALL_CATEGORY_KEY);
}}
onClick={resetMaterialSearch}
>
<SparklesIcon className="size-4" />
重置筛选
......@@ -948,49 +1009,163 @@ export default function ResourceBookingPage() {
</Panel>
<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-2 text-sm font-medium text-zinc-600">搜索材料</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 className="mb-2 text-sm font-medium text-zinc-600">材料标题</div>
<InputGroup>
<MagnifyingGlassIcon data-slot="icon" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="按标题、编号或文件名搜索"
value={materialSearchForm.materialTitle}
onChange={(event) =>
updateMaterialSearchField('materialTitle', event.target.value)
}
placeholder="例如:函证回函"
/>
</InputGroup>
</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 className="mb-2 text-sm font-medium text-zinc-600">来源应用</div>
<select
value={sourceAppFilter}
onChange={(event) => setSourceAppFilter(event.target.value)}
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"
>
<option value="">全部来源</option>
<Input
list="source-app-search-suggestions"
value={materialSearchForm.sourceApp}
onChange={(event) =>
updateMaterialSearchField('sourceApp', event.target.value)
}
placeholder="例如:材料中心 / 中台"
/>
<datalist id="source-app-search-suggestions">
{sourceOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</datalist>
</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
list="tag-suggestions"
value={tagFilter}
onChange={(event) => setTagFilter(event.target.value)}
placeholder="例如:项目一期"
list="source-system-search-suggestions"
value={materialSearchForm.sourceSystem}
onChange={(event) =>
updateMaterialSearchField('sourceSystem', event.target.value)
}
placeholder="例如:审计材料管理系统 / 中台"
/>
<datalist id="tag-suggestions">
{tagSuggestions.map((item) => (
<option key={item} value={item} />
<datalist id="source-system-search-suggestions">
{sourceSystemOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</datalist>
</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 className="mt-6 flex flex-wrap gap-2">
......@@ -1001,7 +1176,7 @@ export default function ResourceBookingPage() {
label={item.label}
count={item.count}
icon={item.icon}
onClick={() => setActiveCategory(item.key)}
onClick={() => handleCategoryTabClick(item.key)}
/>
))}
</div>
......@@ -1068,7 +1243,7 @@ export default function ResourceBookingPage() {
已勾选 {selectedCount} 份材料
</div>
<div className="mt-1 text-sm text-zinc-500">
支持对当前页已勾选材料进行批量修改、批量删除和批量打标签。
支持对当前页已勾选材料进行批量下载/导出、批量修改、批量删除和批量打标签。
</div>
</div>
<div className="flex flex-wrap gap-2">
......@@ -1076,10 +1251,19 @@ export default function ResourceBookingPage() {
plain
className="cursor-pointer"
onClick={clearSelection}
disabled={selectedCount === 0 || saving}
disabled={selectedCount === 0 || saving || exporting}
>
清空勾选
</Button>
<Button
outline
className="cursor-pointer"
onClick={handleBatchExport}
disabled={selectedCount === 0 || saving || exporting}
>
<ArrowDownTrayIcon className="size-4" />
{exporting ? '导出中...' : '批量下载/导出'}
</Button>
<Button
outline
className="cursor-pointer"
......@@ -1087,7 +1271,7 @@ export default function ResourceBookingPage() {
resetBatchEditState();
setIsBatchEditOpen(true);
}}
disabled={selectedCount === 0 || saving}
disabled={selectedCount === 0 || saving || exporting}
>
批量修改
</Button>
......@@ -1098,7 +1282,7 @@ export default function ResourceBookingPage() {
resetBatchTagState();
setIsBatchTagOpen(true);
}}
disabled={selectedCount === 0 || saving}
disabled={selectedCount === 0 || saving || exporting}
>
批量打标签
</Button>
......@@ -1106,7 +1290,7 @@ export default function ResourceBookingPage() {
color="red"
className="cursor-pointer"
onClick={handleBatchDelete}
disabled={selectedCount === 0 || saving}
disabled={selectedCount === 0 || saving || exporting}
>
批量删除
</Button>
......@@ -1129,6 +1313,7 @@ export default function ResourceBookingPage() {
<TableHeader>文件信息</TableHeader>
<TableHeader>分类 / 标签</TableHeader>
<TableHeader>来源应用</TableHeader>
<TableHeader>来源系统</TableHeader>
<TableHeader>上传人</TableHeader>
<TableHeader>上传时间</TableHeader>
</TableRow>
......@@ -1192,24 +1377,19 @@ export default function ResourceBookingPage() {
<Dialog
open={isDetailOpen}
onClose={() => {
if (saving) {
return;
}
setIsDetailOpen(false);
}}
onClose={handleCloseDetail}
size="4xl"
>
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle>材料详情</DialogTitle>
<DialogDescription>
当前详情表单已按最新对象文档拆成基础信息、提供信息与合规属性,便于边查看边维护材料元数据。
默认以只读方式查看材料元数据;点击编辑后再修改,保存成功后生效。
</DialogDescription>
</div>
<button
type="button"
onClick={() => setIsDetailOpen(false)}
onClick={handleCloseDetail}
disabled={saving}
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"
......@@ -1222,7 +1402,6 @@ export default function ResourceBookingPage() {
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<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>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
......@@ -1287,6 +1466,18 @@ export default function ResourceBookingPage() {
</div>
<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
eyebrow="BASIC INFO"
title="基础信息"
......@@ -1297,6 +1488,7 @@ export default function ResourceBookingPage() {
<FieldLabel required>材料标题</FieldLabel>
<Input
value={detailForm.materialTitle}
disabled={!isDetailEditing || saving}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
......@@ -1310,6 +1502,7 @@ export default function ResourceBookingPage() {
<Input
list="source-app-suggestions"
value={detailForm.sourceApp}
disabled={!isDetailEditing || saving}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
......@@ -1319,11 +1512,27 @@ export default function ResourceBookingPage() {
placeholder="可选"
/>
</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>
<FieldLabel>材料分类</FieldLabel>
<Input
list="archive-category-suggestions"
value={detailForm.archiveCategory}
disabled={!isDetailEditing || saving}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
......@@ -1338,6 +1547,7 @@ export default function ResourceBookingPage() {
<Input
list="tag-suggestions"
value={detailForm.tag}
disabled={!isDetailEditing || saving}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
......@@ -1360,6 +1570,7 @@ export default function ResourceBookingPage() {
<FieldLabel>提供部门</FieldLabel>
<Input
value={detailForm.provideDepartment}
disabled={!isDetailEditing || saving}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
......@@ -1374,6 +1585,7 @@ export default function ResourceBookingPage() {
<Input
type="datetime-local"
value={detailForm.provideTime}
disabled={!isDetailEditing || saving}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
......@@ -1395,6 +1607,7 @@ export default function ResourceBookingPage() {
<Checkbox
color="sky"
checked={detailForm.isConfidential}
disabled={!isDetailEditing || saving}
onChange={(checked) =>
setDetailForm((prev) => ({
...prev,
......@@ -1415,6 +1628,7 @@ export default function ResourceBookingPage() {
<Checkbox
color="sky"
checked={detailForm.isAuditDeptMaterial}
disabled={!isDetailEditing || saving}
onChange={(checked) =>
setDetailForm((prev) => ({
...prev,
......@@ -1441,6 +1655,11 @@ export default function ResourceBookingPage() {
<option key={item.value} value={item.value} />
))}
</datalist>
<datalist id="source-system-suggestions">
{sourceSystemOptions.map((item) => (
<option key={item.value} value={item.value} />
))}
</datalist>
<datalist id="archive-category-suggestions">
{archiveCategorySuggestions.map((item) => (
<option key={item} value={item} />
......@@ -1460,6 +1679,16 @@ export default function ResourceBookingPage() {
>
{saving ? '处理中...' : '删除材料'}
</Button>
{isDetailEditing ? (
<>
<Button
outline
className="cursor-pointer"
onClick={handleCancelDetailEdit}
disabled={saving || !activeMaterial}
>
取消编辑
</Button>
<Button
color="blue"
className="cursor-pointer"
......@@ -1468,6 +1697,17 @@ export default function ResourceBookingPage() {
>
{saving ? '保存中...' : '保存修改'}
</Button>
</>
) : (
<Button
color="blue"
className="cursor-pointer"
onClick={handleStartDetailEdit}
disabled={saving || !activeMaterial}
>
编辑
</Button>
)}
</DialogActions>
</Dialog>
......@@ -1532,6 +1772,41 @@ export default function ResourceBookingPage() {
/>
</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">
<Checkbox
color="sky"
......@@ -1979,6 +2254,20 @@ export default function ResourceBookingPage() {
placeholder="可选,例如 中台"
/>
</div>
<div>
<FieldLabel>来源系统</FieldLabel>
<Input
list="source-system-suggestions"
value={uploadForm.sourceSystem}
onChange={(event) =>
setUploadForm((prev) => ({
...prev,
sourceSystem: event.target.value,
}))
}
placeholder="可选,例如 审计材料管理系统"
/>
</div>
<div>
<FieldLabel>材料分类</FieldLabel>
<Input
......@@ -2105,6 +2394,9 @@ export default function ResourceBookingPage() {
<Textarea
value={[
uploadForm.sourceApp ? `来源应用:${uploadForm.sourceApp}` : '',
uploadForm.sourceSystem
? `来源系统:${uploadForm.sourceSystem}`
: '',
uploadForm.archiveCategory
? `材料分类:${uploadForm.archiveCategory}`
: '',
......@@ -2126,6 +2418,11 @@ export default function ResourceBookingPage() {
<option key={item.value} value={item.value} />
))}
</datalist>
<datalist id="source-system-suggestions">
{sourceSystemOptions.map((item) => (
<option key={item.value} value={item.value} />
))}
</datalist>
<datalist id="archive-category-suggestions">
{archiveCategorySuggestions.map((item) => (
<option key={item} value={item} />
......
......@@ -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() {
const userInfo = useContext(UserContext);
const [materials, setMaterials] = useState([]);
......@@ -310,121 +357,42 @@ export default function ResourceStatPage() {
};
}, [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(
() => dashboard.categoryGroups.slice(0, 4),
[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 (
<div className="space-y-6">
<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="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
......@@ -439,16 +407,7 @@ export default function ResourceStatPage() {
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:w-[26rem]">
<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="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">
最近 7 天新增
......@@ -476,7 +435,7 @@ export default function ResourceStatPage() {
{loading ? (
<div className="mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700">
正在加载材料总览...
正在加载工作台...
</div>
) : null}
</div>
......@@ -497,6 +456,13 @@ export default function ResourceStatPage() {
hint="跨应用汇入的来源数量"
tone="green"
/>
<MetricCard
icon={CircleStackIcon}
label="来源系统"
value={dashboard.uniqueSourceSystemCount}
hint="跨系统汇入的来源数量"
tone="blue"
/>
<MetricCard
icon={BuildingOffice2Icon}
label="提供信息完整"
......@@ -566,6 +532,26 @@ export default function ResourceStatPage() {
</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 className="p-6">
......@@ -609,42 +595,118 @@ export default function ResourceStatPage() {
title="提供与合规"
description="根据最新字段快速看清提供信息补录进度,以及涉密、审计部材料的标记情况。"
/>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(20rem,0.85fr)] xl:items-stretch">
<ChartPanel
option={governanceOption}
className="h-[320px] sm:h-[340px] xl:h-[360px]"
emptyText="暂无提供与合规数据"
/>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50/80 px-4 py-4">
<div className="text-sm text-zinc-500">已补齐提供信息</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
<div className="grid gap-4 xl:grid-cols-3">
<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-3 flex items-end gap-2">
<span className="text-4xl font-semibold tabular-nums text-zinc-950">
{providedInfoPercent}%
</span>
<span className="pb-1 text-sm text-zinc-500">
{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 text-zinc-500">
缺少部门 {dashboard.missingProvideDepartmentCount},缺少时间{' '}
<div className="mt-1 text-xs leading-5 text-zinc-500">已补齐</div>
</div>
<div>
<div className="font-semibold tabular-nums text-zinc-950">
{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 className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4">
<div className="text-sm font-medium text-zinc-700">主要提供部门</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs leading-5 text-amber-800">
待补录 {missingProvideInfoCount} 份,优先补齐提供部门和提供时间。
</div>
</div>
<div className="rounded-[24px] border border-zinc-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-zinc-500">合规标记</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">
分别关注敏感材料、审计部材料与常规材料占比。
</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-[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, 4).map((item) => (
<span
key={item.key}
className="inline-flex rounded-full border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-700"
>
{item.label} · {item.count}
</span>
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="text-sm text-zinc-400">当前还没有已登记的提供部门</div>
)}
</div>
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white px-4 py-5 text-sm text-zinc-400">
当前还没有已登记的提供部门
</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>
</div>
......@@ -655,7 +717,6 @@ export default function ResourceStatPage() {
</div>
</Panel>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Panel className="p-6">
<SectionHeader
title="最近上传"
......@@ -677,7 +738,7 @@ export default function ResourceStatPage() {
{item.tag ? <TagPill>{item.tag}</TagPill> : null}
</div>
<div className="mt-2 text-sm text-zinc-500">
{item.sourceAppLabel} · {item.uploaderLabel} · {item.fileSizeLabel}
{item.sourceAppLabel} · {item.sourceSystemLabel} · {item.uploaderLabel} · {item.fileSizeLabel}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{item.isConfidential ? <TagPill tone="amber">涉密</TagPill> : null}
......@@ -699,35 +760,6 @@ export default function ResourceStatPage() {
</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}
</div>
<div className="mt-2 text-xs leading-6 text-amber-800">
{item.attentionReasons.join(' · ')}
</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">当前没有待完善材料</div>
)}
</div>
</Panel>
</div>
</div>
{!loading && materials.length === 0 ? (
<Panel className="p-8 text-center text-sm text-zinc-500">
当前没有可展示的材料记录。
......
......@@ -173,6 +173,7 @@
"url": "https://oss.example.com/audit/xxx.pdf"
},
"source_app": "中台",
"source_system": "middle_platform",
"archive_category": "试试水",
"uploader": "69b12a47e92b1de77c94a9f5",
"uploaded_at": "2026-05-09T08:30:00.000Z",
......@@ -192,6 +193,7 @@
"page_size": 20,
"filter": {
"metadata.source_app": "project_manager_xy",
"metadata.source_system": "project_manager_xy",
"metadata.archive_category": "审计证据",
"metadata.tag": "项目一期"
},
......@@ -220,6 +222,7 @@
"url": "https://oss.streams7.com/xinyuan/3WCqMeKoiqOc.xlsx"
},
"source_app": "中台",
"source_system": "middle_platform",
"archive_category": "试试水",
"uploader": "64b6575b80bdbd9b3d65d09b",
"uploaded_at": "2026-05-08T16:00:00.000Z",
......@@ -234,4 +237,4 @@
- `title`、`content_type`、`metadata`、`subject` 这一层结构正确
- `file_asset` 的 `name/type/size/url` 结构正确
- `uploader` 直接传用户 `_id` 字符串正确
- `source_app` 和 `archive_category` 现在已按你的要求改成非必填
- `source_app`、`source_system` 和 `archive_category` 现在已按你的要求改成非必填
......@@ -101,6 +101,7 @@ type Record = {
| 材料标题 | `material_title` | text | 否 | 否 | 是 | 业务展示主标题 |
| 文件对象 | `file_asset` | file | 否 | 否 | 是 | 上传接口返回的文件对象 |
| 来源应用 | `source_app` | text | 否 | 否 | 否 | 材料最初产生于哪个应用 |
| 来源系统 | `source_system` | text | 否 | 否 | 否 | 材料最初产生于哪个系统 |
| 材料分类 | `archive_category` | text | 否 | 否 | 否 | 用户自定义分类 |
| 上传人 | `uploader` | user | 否 | 否 | 是 | 当前登录用户 |
| 上传时间 | `uploaded_at` | date | 否 | 否 | 是 | 创建主档时间 |
......@@ -123,6 +124,7 @@ type Record = {
"url": "https://oss.example.com/audit/meeting-note.pdf"
},
"source_app": "project_manager_xy",
"source_system": "project_manager_xy",
"archive_category": "审计证据",
"uploader": "69b12a47e92b1de77c94a9f5",
"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