Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
A
audit_material_manage
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
audit_material_manage
Commits
a455bb75
Commit
a455bb75
authored
Jun 15, 2026
by
David Yang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 应用切换
parent
a3dfaf51
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
1686 additions
and
425 deletions
+1686
-425
.DS_Store
.DS_Store
+0
-0
.umirc.ts
.umirc.ts
+2
-0
favicon.ico
public/favicon.ico
+0
-0
manifest.json
public/manifest.json
+8
-0
.DS_Store
src/.DS_Store
+0
-0
.DS_Store
src/catalyst-ui-kit/.DS_Store
+0
-0
ApplicationSwitcherDialog.jsx
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
+496
-0
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+71
-15
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+84
-25
materialService.js
src/pages/project_manager_xy/materialService.js
+349
-43
index.jsx
src/pages/project_manager_xy/resource-booking/index.jsx
+416
-119
index.jsx
src/pages/project_manager_xy/resource-stat/index.jsx
+254
-222
record-api.md
src/services/record-api.md
+4
-1
subjectsDefinition.md
src/services/subjectsDefinition.md
+2
-0
No files found.
.DS_Store
View file @
a455bb75
No preview for this file type
.umirc.ts
View file @
a455bb75
...
...
@@ -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'
),
...
...
public/favicon.ico
0 → 100644
View file @
a455bb75
15 KB
public/manifest.json
0 → 100644
View file @
a455bb75
{
"name"
:
"audit_material_manager"
,
"version"
:
"1.0.2"
,
"build"
:
"abc"
,
"description"
:
"merge master"
,
"date"
:
"2025-07-14 19:27:20"
,
"author"
:
"yang"
}
src/.DS_Store
View file @
a455bb75
No preview for this file type
src/catalyst-ui-kit/.DS_Store
View file @
a455bb75
No preview for this file type
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
0 → 100644
View file @
a455bb75
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
>
);
}
src/pages/project_manager_xy/Layout.jsx
View file @
a455bb75
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"
/>
)
:
null
}
{
!
sidebarCollapsed
?
(
<
h1
className=
"truncate text-sm font-semibold tracking-[0.02em] text-white/95"
>
{
APP_NAME
}
</
h1
>
)
:
null
}
<>
<
img
src=
{
xyLogoSmall
}
alt=
"XY Logo"
className=
"h-8 w-auto max-w-8 shrink-0 object-contain"
/>
{
applicationSwitcherButton
}
</>
)
:
(
<>
<
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
>
<
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
>
);
}
src/pages/project_manager_xy/audit-material-center.business-flow.md
View file @
a455bb75
...
...
@@ -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 中会跳过失败项并提示用户
-
批量修改仅允许覆盖用户显式勾选的字段,未勾选字段必须保留原值,避免误覆盖
-
批量删除前必须二次确认,默认仍按软删除处理
-
当前版本的批量勾选范围限定在“当前筛选结果的当前页”,切换分页后需要重新确认勾选结果
...
...
src/pages/project_manager_xy/materialService.js
View file @
a455bb75
...
...
@@ -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
matches
Keyword
&&
matchesSource
&&
matchesCategory
&&
matchesTag
;
return
matches
Category
&&
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
;
}
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
)
{
filter
[
'$or'
]
=
isUncategorizedCategory
(
filters
.
archiveCategory
)
?
[
{
'metadata.archive_category'
:
''
},
{
'metadata.archive_category'
:
null
},
{
'metadata.archive_category'
:
{
$exists
:
false
}
},
]
:
[{
'metadata.archive_category'
:
filters
.
archiveCategory
}];
if
(
conditions
.
length
===
0
)
{
return
{};
}
if
(
filters
.
tag
)
{
filter
[
'metadata.tag'
]
=
filters
.
tag
;
if
(
conditions
.
length
===
1
)
{
return
conditions
[
0
]
;
}
return
filter
;
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
(
/
[\u
0000-
\u
001f
]
/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
;
...
...
src/pages/project_manager_xy/resource-booking/index.jsx
View file @
a455bb75
...
...
@@ -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
;
};
},
[
active
Category
,
activeMaterialId
,
deferredQuery
,
page
,
pageSize
,
sourceAppFilter
,
tagFilter
]);
},
[
active
MaterialId
,
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-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
>
<
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
>
))
}
</
selec
t
>
</
datalis
t
>
</
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,14 +1679,35 @@ export default function ResourceBookingPage() {
>
{
saving
?
'处理中...'
:
'删除材料'
}
</
Button
>
<
Button
color=
"blue"
className=
"cursor-pointer"
onClick=
{
handleSaveDetail
}
disabled=
{
saving
||
!
activeMaterial
}
>
{
saving
?
'保存中...'
:
'保存修改'
}
</
Button
>
{
isDetailEditing
?
(
<>
<
Button
outline
className=
"cursor-pointer"
onClick=
{
handleCancelDetailEdit
}
disabled=
{
saving
||
!
activeMaterial
}
>
取消编辑
</
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
>
</
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
}
/>
...
...
src/pages/project_manager_xy/resource-stat/index.jsx
View file @
a455bb75
...
...
@@ -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=
"提供信息完整"
...
...
@@ -541,11 +507,11 @@ export default function ResourceStatPage() {
<
div
className=
"grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"来源应用分布"
description=
"帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。"
/>
<
ChartPanel
option=
{
sourceDistributionOption
}
emptyText=
"暂无来源分布数据"
/>
<
div
className=
"mt-4 grid gap-3 sm:grid-cols-3"
>
title=
"来源应用分布"
description=
"帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。"
/>
<
ChartPanel
option=
{
sourceDistributionOption
}
emptyText=
"暂无来源分布数据"
/>
<
div
className=
"mt-4 grid gap-3 sm:grid-cols-3"
>
{
dashboard
.
sourceGroups
.
slice
(
0
,
3
).
map
((
item
,
index
)
=>
(
<
div
key=
{
item
.
key
}
...
...
@@ -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"
>
{
dashboard
.
providedInfoCompleteCount
}
<
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 leading-5 text-zinc-500"
>
已补齐
</
div
>
</
div
>
<
div
className=
"mt-1 text-xs text-zinc-500"
>
缺少部门
{
dashboard
.
missingProvideDepartmentCount
}
,缺少时间
{
' '
}
{
dashboard
.
missingProvideTimeCount
}
<
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"
>
{
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
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
当前还没有已登记的提供部门
</
div
>
)
}
<
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
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
>
...
...
@@ -655,78 +717,48 @@ export default function ResourceStatPage() {
</
div
>
</
Panel
>
<
div
className=
"grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"最近上传"
description=
"快速浏览最近进入材料池的文件,便于发现异常命名、来源遗漏或归档不完整的情况。"
/>
<
div
className=
"space-y-3"
>
{
dashboard
.
recentMaterials
.
length
>
0
?
(
dashboard
.
recentMaterials
.
map
((
item
)
=>
(
<
div
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"
>
<
div
className=
"min-w-0"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
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"
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"最近上传"
description=
"快速浏览最近进入材料池的文件,便于发现异常命名、来源遗漏或归档不完整的情况。"
/>
<
div
className=
"space-y-3"
>
{
dashboard
.
recentMaterials
.
length
>
0
?
(
dashboard
.
recentMaterials
.
map
((
item
)
=>
(
<
div
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"
>
<
div
className=
"min-w-0"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
div
className=
"truncate text-sm font-semibold text-zinc-950 sm:text-base"
>
{
item
.
materialTitle
}
</
div
>
<
div
className=
"mt-2 text-xs leading-6 text-amber-800"
>
{
item
.
attentionReasons
.
join
(
' · '
)
}
</
div
>
<
TagPill
>
{
item
.
archiveCategory
}
</
TagPill
>
{
item
.
tag
?
<
TagPill
>
{
item
.
tag
}
</
TagPill
>
:
null
}
</
div
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
当前没有待完善材料
</
div
>
)
}
</
div
>
</
Panel
>
<
div
className=
"mt-2 text-sm text-zinc-500"
>
{
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
}
{
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
>
</
Panel
>
{
!
loading
&&
materials
.
length
===
0
?
(
<
Panel
className=
"p-8 text-center text-sm text-zinc-500"
>
...
...
src/services/record-api.md
View file @
a455bb75
...
...
@@ -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`
现在已按你的要求改成非必填
src/services/subjectsDefinition.md
View file @
a455bb75
...
...
@@ -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"
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment