Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
M
model_analysis
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
model_analysis
Commits
5a3fb30d
Commit
5a3fb30d
authored
Jun 15, 2026
by
David Yang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 应用切换
parent
48eaa28c
Show whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
4132 additions
and
529 deletions
+4132
-529
.umirc.ts
.umirc.ts
+2
-0
manifest.json
public/manifest.json
+8
-0
combobox.jsx
src/catalyst-ui-kit/javascript/combobox.jsx
+1
-1
AnalysisOverview.jsx
src/pages/project_manager_xy/AnalysisOverview.jsx
+74
-116
AnalysisShared.jsx
src/pages/project_manager_xy/AnalysisShared.jsx
+36
-5
AnalysisViews.jsx
src/pages/project_manager_xy/AnalysisViews.jsx
+1346
-182
ApplicationSwitcherDialog.jsx
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
+496
-0
ForwardApprovals.jsx
src/pages/project_manager_xy/ForwardApprovals.jsx
+452
-98
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+73
-7
analysisService.js
src/pages/project_manager_xy/analysisService.js
+1352
-45
audit-material-center.business-flow.md
...project_manager_xy/audit-material-center.business-flow.md
+194
-50
projectTaskService.js
src/pages/project_manager_xy/projectTaskService.js
+1
-1
DataService.js
src/services/DataService.js
+2
-1
subjectsDefinition.md
src/services/subjectsDefinition.md
+95
-23
No files found.
.umirc.ts
View file @
5a3fb30d
...
...
@@ -20,6 +20,8 @@ export default defineConfig({
react
:
resolvePackageRoot
(
'react'
),
'react-dom'
:
resolvePackageRoot
(
'react-dom'
),
},
base
:
'/model_analysis/'
,
publicPath
:
'/model_analysis/'
,
routes
:
[
{
path
:
ROUTE_PATHS
.
login
,
...
...
public/manifest.json
0 → 100644
View file @
5a3fb30d
{
"name"
:
"model_analysis"
,
"version"
:
"1.0.1"
,
"build"
:
"abc"
,
"description"
:
"merge master"
,
"date"
:
"2025-07-14 19:27:20"
,
"author"
:
"yang"
}
src/catalyst-ui-kit/javascript/combobox.jsx
View file @
5a3fb30d
...
...
@@ -100,7 +100,7 @@ export function Combobox({
// Anchor positioning
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]'
,
// Base styles,
'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible'
,
'isolate
z-[160]
min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible'
,
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
'outline outline-transparent focus:outline-hidden'
,
// Handle scrolling when menu won't fit in viewport
...
...
src/pages/project_manager_xy/AnalysisOverview.jsx
View file @
5a3fb30d
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
ChartCard
,
HeroBanner
,
MetricCard
,
NoticeBanner
,
Panel
,
SectionHeader
,
Tag
,
}
from
'./AnalysisShared'
;
import
{
ClockIcon
,
ShareIcon
,
}
from
'@heroicons/react/24/outline'
;
import
{
buildOverviewCharts
,
buildMetricCards
,
...
...
@@ -19,6 +19,58 @@ import {
}
from
'./analysisService'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
function
OverviewStatusItem
({
icon
:
Icon
,
label
,
value
,
hint
})
{
return
(
<
div
className=
"flex min-w-0 items-start gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm"
>
<
div
className=
"flex size-9 shrink-0 items-center justify-center rounded-lg bg-rose-50 text-rose-600"
>
<
Icon
className=
"size-[18px]"
/>
</
div
>
<
div
className=
"min-w-0"
>
<
div
className=
"text-xs font-medium text-zinc-500"
>
{
label
}
</
div
>
<
div
className=
"mt-1 truncate text-lg font-semibold leading-6 text-zinc-950"
>
{
value
}
</
div
>
<
div
className=
"mt-0.5 truncate text-xs text-zinc-500"
>
{
hint
}
</
div
>
</
div
>
</
div
>
);
}
function
OverviewHeader
({
welcomeName
,
lastUpdatedAt
,
sharedViewCount
})
{
return
(
<
section
className=
"overflow-hidden rounded-lg border border-zinc-200/80 bg-white shadow-sm"
>
<
div
className=
"grid gap-5 bg-[linear-gradient(135deg,rgba(255,241,242,0.78),rgba(255,255,255,0.96)_42%,rgba(248,250,252,0.98))] p-5 lg:grid-cols-[minmax(0,1fr)_minmax(360px,480px)] lg:items-center lg:p-6"
>
<
div
className=
"min-w-0"
>
<
div
className=
"inline-flex rounded-full border border-rose-200 bg-white/80 px-3 py-1 text-[11px] font-semibold tracking-[0.16em] text-rose-700"
>
ANALYSIS OVERVIEW
</
div
>
<
h1
className=
"mt-4 max-w-4xl text-2xl font-semibold leading-tight text-zinc-950 sm:text-3xl"
>
{
welcomeName
}
,这里是新分析工具的统一驾驶舱
</
h1
>
<
p
className=
"mt-3 max-w-4xl text-sm leading-6 text-zinc-600"
>
聚合视图配置、转发审批和图表看板三条主链路,先判断配置成熟度,再进入具体分析任务。
</
p
>
</
div
>
<
div
className=
"grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"
>
<
OverviewStatusItem
icon=
{
ClockIcon
}
label=
"最近更新时间"
value=
{
lastUpdatedAt
}
hint=
"配置或审批变更"
/>
<
OverviewStatusItem
icon=
{
ShareIcon
}
label=
"共享视图数"
value=
{
sharedViewCount
}
hint=
"团队开放配置"
/>
</
div
>
</
div
>
</
section
>
);
}
export
default
function
AnalysisOverview
()
{
const
userInfo
=
useContext
(
UserContext
);
const
[
subjects
,
setSubjects
]
=
useState
([]);
...
...
@@ -76,6 +128,20 @@ export default function AnalysisOverview() {
);
const
welcomeName
=
userInfo
?.
display_name
||
userInfo
?.
displayName
||
userInfo
?.
username
||
'业务同事'
;
const
lastUpdatedAt
=
useMemo
(
()
=>
formatDateTime
(
[...
views
,
...
forwards
,
...
boards
]
.
map
((
item
)
=>
item
.
updatedAt
||
item
.
createdAt
)
.
sort
()
.
pop
(),
),
[
boards
,
forwards
,
views
],
);
const
sharedViewCount
=
useMemo
(
()
=>
views
.
filter
((
item
)
=>
item
.
scope
===
'shared'
).
length
,
[
views
],
);
const
viewChartOption
=
useMemo
(
()
=>
({
...
...
@@ -159,41 +225,10 @@ export default function AnalysisOverview() {
return
(
<
div
className=
"space-y-6"
>
<
HeroBanner
eyebrow=
"ANALYSIS OVERVIEW"
title=
{
`${welcomeName},这里是新分析工具的统一驾驶舱`
}
description=
"总览页聚合对象覆盖、视图配置、转发审批和图表看板四条主链路,方便业务先看配置成熟度,再进入视图或审批页处理具体分析任务。"
aside=
{
<
div
className=
"grid gap-3 sm:grid-cols-2"
>
<
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"
>
{
formatDateTime
(
[...
views
,
...
forwards
,
...
boards
]
.
map
((
item
)
=>
item
.
updatedAt
||
item
.
createdAt
)
.
sort
()
.
pop
(),
)
}
</
div
>
<
div
className=
"mt-1 text-sm text-zinc-500"
>
最新配置或审批变更时间
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm"
>
<
div
className=
"text-xs font-semibold tracking-[0.18em] text-zinc-500"
>
共享视图数
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-zinc-950"
>
{
views
.
filter
((
item
)
=>
item
.
scope
===
'shared'
).
length
}
</
div
>
<
div
className=
"mt-1 text-sm text-zinc-500"
>
已对团队开放的分析配置
</
div
>
</
div
>
</
div
>
}
<
OverviewHeader
welcomeName=
{
welcomeName
}
lastUpdatedAt=
{
lastUpdatedAt
}
sharedViewCount=
{
sharedViewCount
}
/>
{
loading
?
<
NoticeBanner
>
正在加载分析总览...
</
NoticeBanner
>
:
null
}
...
...
@@ -230,83 +265,6 @@ export default function AnalysisOverview() {
emptyText=
"还没有图表看板"
/>
</
div
>
<
div
className=
"grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"对象覆盖"
description=
"后端对象定义是整个分析工具的底座,新增对象后可以直接在视图页配置。"
/>
<
div
className=
"grid gap-3"
>
{
subjects
.
length
>
0
?
(
subjects
.
map
((
subject
)
=>
(
<
div
key=
{
subject
.
name
}
className=
"rounded-[22px] border border-zinc-200 bg-zinc-50/80 px-4 py-4"
>
<
div
className=
"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<
div
>
<
div
className=
"text-base font-semibold text-zinc-950"
>
{
subject
.
title
||
subject
.
name
}
</
div
>
<
div
className=
"mt-1 text-sm text-zinc-500"
>
{
subject
.
description
||
'当前对象已可用于配置分析视图。'
}
</
div
>
</
div
>
<
Tag
tone=
"sky"
>
{
subject
.
fields
.
length
}
个字段
</
Tag
>
</
div
>
</
div
>
))
)
:
(
<
div
className=
"text-sm text-zinc-400"
>
暂无可用对象定义
</
div
>
)
}
</
div
>
</
Panel
>
<
Panel
className=
"p-6"
>
<
SectionHeader
title=
"最近活动"
description=
"把最近新增或更新的视图、审批和看板放在一起,方便判断业务关注点。"
/>
<
div
className=
"space-y-3"
>
{
[...
views
,
...
forwards
,
...
boards
]
.
sort
((
left
,
right
)
=>
String
(
right
.
updatedAt
||
right
.
createdAt
).
localeCompare
(
String
(
left
.
updatedAt
||
left
.
createdAt
),
),
)
.
slice
(
0
,
6
)
.
map
((
item
)
=>
(
<
div
key=
{
`${item.id}_${item.updatedAt || item.createdAt}`
}
className=
"rounded-[22px] border border-zinc-200 bg-white px-4 py-4"
>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-sm font-semibold text-zinc-950"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-1 text-xs text-zinc-500"
>
更新于
{
formatDateTime
(
item
.
updatedAt
||
item
.
createdAt
)
}
</
div
>
</
div
>
{
'approvalStatus'
in
item
?
(
<
Tag
tone=
"amber"
>
审批记录
</
Tag
>
)
:
'widgets'
in
item
?
(
<
Tag
tone=
"emerald"
>
图表看板
</
Tag
>
)
:
(
<
Tag
tone=
"sky"
>
分析视图
</
Tag
>
)
}
</
div
>
</
div
>
))
}
{
views
.
length
+
forwards
.
length
+
boards
.
length
===
0
?
(
<
div
className=
"text-sm text-zinc-400"
>
当前还没有配置活动
</
div
>
)
:
null
}
</
div
>
</
Panel
>
</
div
>
</
div
>
);
}
src/pages/project_manager_xy/AnalysisShared.jsx
View file @
5a3fb30d
...
...
@@ -38,6 +38,8 @@ import {
formatDateTime
,
formatFieldValue
,
getApprovalStatusLabel
,
getForwardRowStatusLabel
,
getForwardRowStatusTone
,
getFieldInputKind
,
getViewScopeLabel
,
loadAnalysisSubjectDefinition
,
...
...
@@ -153,6 +155,10 @@ export function ApprovalStatusTag({ status }) {
return
<
Tag
tone=
{
tone
}
>
{
getApprovalStatusLabel
(
status
)
}
</
Tag
>;
}
export
function
WorkflowStatusTag
({
status
})
{
return
<
Tag
tone=
{
getForwardRowStatusTone
(
status
)
}
>
{
getForwardRowStatusLabel
(
status
)
}
</
Tag
>;
}
export
function
EmptyCollection
({
title
,
description
})
{
return
(
<
div
className=
"rounded-[24px] border border-dashed border-zinc-200 bg-zinc-50 px-5 py-10 text-center"
>
...
...
@@ -258,6 +264,7 @@ export function UserSelectField({
required
=
false
,
placeholder
=
'搜索用户'
,
})
{
const
[
query
,
setQuery
]
=
useState
(
''
);
const
[
options
,
setOptions
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
false
);
...
...
@@ -266,17 +273,22 @@ export function UserSelectField({
(
async
()
=>
{
setLoading
(
true
);
const
result
=
await
searchUsers
(
''
);
try
{
const
result
=
await
searchUsers
(
query
);
if
(
!
cancelled
)
{
setOptions
(
result
);
}
}
finally
{
if
(
!
cancelled
)
{
setLoading
(
false
);
}
}
})();
return
()
=>
{
cancelled
=
true
;
};
},
[]);
},
[
query
]);
return
(
<
div
>
...
...
@@ -291,6 +303,7 @@ export function UserSelectField({
.
toLowerCase
()
.
includes
(
String
(
query
||
''
).
toLowerCase
())
}
onQueryChange=
{
setQuery
}
placeholder=
{
placeholder
}
aria
-
label=
{
label
}
>
...
...
@@ -645,6 +658,9 @@ export function RecordsResultTable({
records
,
columns
,
selectedIds
,
rowStatusByRecordId
=
{},
forwardItemByRecordId
=
new
Map
(),
onOpenForwardItem
,
onToggleRow
,
onToggleAll
,
})
{
...
...
@@ -663,6 +679,7 @@ export function RecordsResultTable({
/>
</
TableHeader
>
<
TableHeader
>
标题
</
TableHeader
>
<
TableHeader
>
当前状态
</
TableHeader
>
{
columns
.
map
((
column
)
=>
(
<
TableHeader
key=
{
column
.
alias
}
>
{
column
.
label
}
</
TableHeader
>
))
}
...
...
@@ -670,7 +687,11 @@ export function RecordsResultTable({
</
TableRow
>
</
TableHead
>
<
TableBody
>
{
records
.
map
((
record
)
=>
(
{
records
.
map
((
record
)
=>
{
const
recordKey
=
record
.
recordName
||
record
.
id
;
const
forwardItem
=
forwardItemByRecordId
.
get
(
String
(
recordKey
));
return
(
<
TableRow
key=
{
record
.
id
}
>
<
TableCell
>
<
Checkbox
...
...
@@ -681,7 +702,17 @@ export function RecordsResultTable({
/>
</
TableCell
>
<
TableCell
className=
"font-medium text-zinc-950"
>
<
button
type=
"button"
onClick=
{
()
=>
onOpenForwardItem
?.(
record
,
forwardItem
||
null
)
}
className=
"max-w-72 truncate text-left font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline"
aria
-
label=
{
`查看 ${record.title || record.id} 的回填详情`
}
>
{
record
.
title
||
'-'
}
</
button
>
</
TableCell
>
<
TableCell
>
<
WorkflowStatusTag
status=
{
rowStatusByRecordId
[
recordKey
]
||
'pending_send'
}
/>
</
TableCell
>
{
columns
.
map
((
column
)
=>
(
<
TableCell
key=
{
column
.
alias
}
className=
"text-zinc-600"
>
...
...
@@ -692,7 +723,8 @@ export function RecordsResultTable({
{
formatDateTime
(
record
.
updatedAt
)
}
</
TableCell
>
</
TableRow
>
))
}
);
})
}
</
TableBody
>
</
Table
>
);
...
...
@@ -705,4 +737,3 @@ export function ForwardStatusOptionsHint() {
</
div
>
);
}
src/pages/project_manager_xy/AnalysisViews.jsx
View file @
5a3fb30d
...
...
@@ -24,9 +24,23 @@ import {
ArrowsPointingInIcon
,
ArrowsPointingOutIcon
,
ChartBarIcon
,
ChevronDownIcon
,
ChevronRightIcon
,
FolderIcon
,
FolderPlusIcon
,
PencilSquareIcon
,
PlusIcon
,
TagIcon
,
TrashIcon
,
}
from
'@heroicons/react/24/outline'
;
import
{
startTransition
,
useContext
,
useDeferredValue
,
useEffect
,
useMemo
,
useState
,
}
from
'react'
;
import
{
ChartCard
,
DynamicFieldInput
,
...
...
@@ -36,6 +50,7 @@ import {
NoticeBanner
,
Panel
,
RecordsResultTable
,
SearchInput
,
SectionHeader
,
SelectionToolbar
,
Tag
,
...
...
@@ -45,18 +60,34 @@ import {
import
{
buildChartOption
,
buildDefaultViewDraft
,
buildForwardItemIndex
,
buildForwardStatusIndex
,
buildGroupTree
,
CHART_TYPE_OPTIONS
,
createEmptyForwardDraft
,
deleteAnalysisGroup
,
deleteAnalysisView
,
ensureForwardRecordItems
,
formatDateTime
,
getFieldInputKind
,
getForwardRowStatusLabel
,
getTruthStatusLabel
,
loadAnalysisGroups
,
loadAnalysisRecordsByIds
,
loadAnalysisSubjectDefinition
,
loadAnalysisViews
,
loadForwardItems
,
loadForwardRecords
,
loadViewRecordCounts
,
queryAnalysisRecords
,
resolveForwardRowStatus
,
saveAnalysisGroup
,
saveAnalysisView
,
saveForwardRecord
,
deleteAnalysisView
,
searchAnalysisSubjects
,
}
from
'./analysisService'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
const
ROOT_GROUP_ID
=
'__all__'
;
function
buildEmptyViewForm
()
{
return
{
...
...
@@ -69,6 +100,18 @@ function buildEmptyViewForm() {
defaultFilters
:
{},
columns
:
[],
charts
:
[],
groupId
:
''
,
groupName
:
''
,
tags
:
[],
hiddenRecordIds
:
[],
};
}
function
buildEmptyGroupForm
()
{
return
{
recordName
:
''
,
groupName
:
''
,
parentGroupId
:
''
,
};
}
...
...
@@ -104,6 +147,37 @@ function mergeSubjectsByName(subjects = []) {
return
Array
.
from
(
subjectMap
.
values
());
}
function
flattenGroupOptions
(
groups
=
[],
depth
=
0
)
{
return
groups
.
flatMap
((
group
)
=>
[
{
id
:
group
.
id
,
label
:
`
${
' '
.
repeat
(
depth
)}${
group
.
groupName
||
group
.
title
}
`
,
},
...
flattenGroupOptions
(
group
.
children
||
[],
depth
+
1
),
]);
}
function
collectDescendantIds
(
groups
=
[],
targetId
)
{
if
(
!
targetId
)
{
return
new
Set
();
}
const
target
=
groups
.
find
((
group
)
=>
group
.
id
===
targetId
);
if
(
!
target
)
{
return
new
Set
();
}
const
descendants
=
new
Set
([
target
.
id
]);
groups
.
forEach
((
group
)
=>
{
if
(
group
.
parentGroupId
===
target
.
id
)
{
collectDescendantIds
(
groups
,
group
.
id
).
forEach
((
id
)
=>
descendants
.
add
(
id
));
}
});
return
descendants
;
}
function
SubjectSearchCombobox
({
subjects
,
value
,
...
...
@@ -138,11 +212,143 @@ function SubjectSearchCombobox({
);
}
function
GroupTreeItem
({
group
,
depth
=
0
,
activeGroupId
,
expandedGroupIds
,
onToggle
,
onSelect
,
})
{
const
hasChildren
=
(
group
.
children
||
[]).
length
>
0
;
const
expanded
=
expandedGroupIds
.
includes
(
group
.
id
);
return
(
<
div
>
<
div
className=
{
`flex items-center gap-1 rounded-2xl px-2 py-1.5 transition ${
activeGroupId === group.id ? 'bg-rose-50' : 'hover:bg-zinc-50'
}`
}
style=
{
{
paddingLeft
:
`${depth * 14 + 8}px`
}
}
>
<
button
type=
"button"
onClick=
{
()
=>
(
hasChildren
?
onToggle
(
group
.
id
)
:
onSelect
(
group
.
id
))
}
aria
-
label=
{
hasChildren
?
`${expanded ? '收起' : '展开'} ${group.groupName}`
:
group
.
groupName
}
className=
"inline-flex h-8 w-8 items-center justify-center rounded-xl text-zinc-400 transition hover:bg-white hover:text-zinc-700"
>
{
hasChildren
?
(
expanded
?
(
<
ChevronDownIcon
className=
"size-4"
/>
)
:
(
<
ChevronRightIcon
className=
"size-4"
/>
)
)
:
(
<
FolderIcon
className=
"size-4"
/>
)
}
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
onSelect
(
group
.
id
)
}
className=
{
`min-w-0 flex-1 rounded-xl px-2 py-1.5 text-left text-sm font-medium transition ${
activeGroupId === group.id
? 'bg-white text-rose-700 shadow-sm'
: 'text-zinc-700 hover:text-zinc-950'
}`
}
>
<
span
className=
"truncate"
>
{
group
.
groupName
}
</
span
>
</
button
>
</
div
>
{
hasChildren
&&
expanded
?
(
<
div
className=
"mt-1 space-y-1"
>
{
group
.
children
.
map
((
child
)
=>
(
<
GroupTreeItem
key=
{
child
.
id
}
group=
{
child
}
depth=
{
depth
+
1
}
activeGroupId=
{
activeGroupId
}
expandedGroupIds=
{
expandedGroupIds
}
onToggle=
{
onToggle
}
onSelect=
{
onSelect
}
/>
))
}
</
div
>
)
:
null
}
</
div
>
);
}
function
TagEditor
({
values
,
onChange
,
placeholder
=
'输入标签后回车'
})
{
const
[
draft
,
setDraft
]
=
useState
(
''
);
function
addTag
(
nextTag
)
{
const
normalized
=
String
(
nextTag
||
''
).
trim
();
if
(
!
normalized
)
{
return
;
}
if
(
values
.
includes
(
normalized
))
{
setDraft
(
''
);
return
;
}
onChange
([...
values
,
normalized
]);
setDraft
(
''
);
}
return
(
<
div
>
<
div
className=
"flex gap-3"
>
<
Input
value=
{
draft
}
placeholder=
{
placeholder
}
onChange=
{
(
event
)
=>
setDraft
(
event
.
target
.
value
)
}
onKeyDown=
{
(
event
)
=>
{
if
(
event
.
key
===
'Enter'
||
event
.
key
===
','
||
event
.
key
===
','
)
{
event
.
preventDefault
();
addTag
(
draft
);
}
}
}
/>
<
Button
outline
onClick=
{
()
=>
addTag
(
draft
)
}
>
<
TagIcon
className=
"size-4"
/>
添加
</
Button
>
</
div
>
<
div
className=
"mt-3 flex flex-wrap gap-2"
>
{
values
.
length
>
0
?
(
values
.
map
((
tag
)
=>
(
<
Tag
key=
{
tag
}
tone=
"rose"
>
<
span
>
{
tag
}
</
span
>
<
button
type=
"button"
className=
"ml-2"
onClick=
{
()
=>
onChange
(
values
.
filter
((
item
)
=>
item
!==
tag
))
}
aria
-
label=
{
`移除 ${tag}`
}
>
x
</
button
>
</
Tag
>
))
)
:
(
<
span
className=
"text-sm text-zinc-400"
>
暂未添加标签
</
span
>
)
}
</
div
>
</
div
>
);
}
export
default
function
AnalysisViews
()
{
const
userInfo
=
useContext
(
UserContext
);
const
[
subjects
,
setSubjects
]
=
useState
([]);
const
[
groups
,
setGroups
]
=
useState
([]);
const
[
views
,
setViews
]
=
useState
([]);
const
[
viewCounts
,
setViewCounts
]
=
useState
({});
const
[
viewKeyword
,
setViewKeyword
]
=
useState
(
''
);
const
deferredViewKeyword
=
useDeferredValue
(
viewKeyword
);
const
[
activeTagFilters
,
setActiveTagFilters
]
=
useState
([]);
const
[
activeGroupId
,
setActiveGroupId
]
=
useState
(
ROOT_GROUP_ID
);
const
[
expandedGroupIds
,
setExpandedGroupIds
]
=
useState
([]);
const
[
selectedSubjectName
,
setSelectedSubjectName
]
=
useState
(
''
);
const
[
subjectKeyword
,
setSubjectKeyword
]
=
useState
(
''
);
const
[
subjectSearching
,
setSubjectSearching
]
=
useState
(
false
);
...
...
@@ -153,17 +359,29 @@ export default function AnalysisViews() {
const
[
filterValues
,
setFilterValues
]
=
useState
({});
const
[
records
,
setRecords
]
=
useState
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
currentViewForwards
,
setCurrentViewForwards
]
=
useState
([]);
const
[
currentViewForwardItems
,
setCurrentViewForwardItems
]
=
useState
([]);
const
[
selectedRecordIds
,
setSelectedRecordIds
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
querying
,
setQuerying
]
=
useState
(
false
);
const
[
countingViews
,
setCountingViews
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
notice
,
setNotice
]
=
useState
(
''
);
const
[
isViewDialogOpen
,
setIsViewDialogOpen
]
=
useState
(
false
);
const
[
isGroupDialogOpen
,
setIsGroupDialogOpen
]
=
useState
(
false
);
const
[
isDeleteGroupDialogOpen
,
setIsDeleteGroupDialogOpen
]
=
useState
(
false
);
const
[
isForwardDialogOpen
,
setIsForwardDialogOpen
]
=
useState
(
false
);
const
[
isHiddenRecordsDialogOpen
,
setIsHiddenRecordsDialogOpen
]
=
useState
(
false
);
const
[
isResultFullscreen
,
setIsResultFullscreen
]
=
useState
(
false
);
const
[
viewForm
,
setViewForm
]
=
useState
(
buildEmptyViewForm
);
const
[
groupForm
,
setGroupForm
]
=
useState
(
buildEmptyGroupForm
);
const
[
groupPendingDelete
,
setGroupPendingDelete
]
=
useState
(
null
);
const
[
forwardForm
,
setForwardForm
]
=
useState
(
createEmptyForwardDraft
);
const
[
hiddenRecordsLoading
,
setHiddenRecordsLoading
]
=
useState
(
false
);
const
[
hiddenRecords
,
setHiddenRecords
]
=
useState
([]);
const
[
selectedHiddenRecordIds
,
setSelectedHiddenRecordIds
]
=
useState
([]);
const
[
forwardItemDetail
,
setForwardItemDetail
]
=
useState
(
null
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -171,8 +389,9 @@ export default function AnalysisViews() {
(
async
()
=>
{
setLoading
(
true
);
try
{
const
[
subjectList
,
viewList
]
=
await
Promise
.
all
([
const
[
subjectList
,
groupList
,
viewList
]
=
await
Promise
.
all
([
searchAnalysisSubjects
(
''
,
10
),
loadAnalysisGroups
(),
loadAnalysisViews
(),
]);
...
...
@@ -201,9 +420,11 @@ export default function AnalysisViews() {
}
setSubjects
(
nextSubjects
);
setGroups
(
groupList
);
setViews
(
viewList
);
setExpandedGroupIds
(
groupList
.
map
((
group
)
=>
group
.
id
));
setSelectedSubjectName
(
preferredSubject
);
setActiveViewId
(
viewList
.
find
((
item
)
=>
item
.
subjectName
===
preferredSubject
)?.
id
||
viewList
[
0
]?.
id
||
''
);
setActiveViewId
(
viewList
[
0
]?.
id
||
''
);
setError
(
''
);
}
catch
(
loadError
)
{
if
(
!
cancelled
)
{
...
...
@@ -306,38 +527,102 @@ export default function AnalysisViews() {
};
},
[
isViewDialogOpen
,
viewForm
.
subjectName
]);
const
filteredViews
=
useMemo
(()
=>
{
const
keyword
=
String
(
viewKeyword
||
''
).
trim
().
toLowerCase
();
useEffect
(()
=>
{
let
cancelled
=
false
;
if
(
views
.
length
===
0
)
{
setViewCounts
({});
return
undefined
;
}
(
async
()
=>
{
setCountingViews
(
true
);
try
{
const
result
=
await
loadViewRecordCounts
(
views
);
if
(
!
cancelled
)
{
setViewCounts
(
result
);
}
}
finally
{
if
(
!
cancelled
)
{
setCountingViews
(
false
);
}
}
})();
return
()
=>
{
cancelled
=
true
;
};
},
[
views
]);
if
(
!
keyword
)
{
const
groupTree
=
useMemo
(()
=>
buildGroupTree
(
groups
),
[
groups
]);
const
flatGroupOptions
=
useMemo
(()
=>
flattenGroupOptions
(
groupTree
),
[
groupTree
]);
const
selectedGroup
=
useMemo
(
()
=>
groups
.
find
((
group
)
=>
group
.
id
===
activeGroupId
)
||
null
,
[
activeGroupId
,
groups
],
);
const
visibleGroupIds
=
useMemo
(()
=>
{
if
(
activeGroupId
===
ROOT_GROUP_ID
)
{
return
new
Set
(
views
.
map
((
view
)
=>
view
.
groupId
).
filter
(
Boolean
));
}
return
collectDescendantIds
(
groups
,
activeGroupId
);
},
[
activeGroupId
,
groups
,
views
]);
const
viewsInCurrentGroup
=
useMemo
(()
=>
{
if
(
activeGroupId
===
ROOT_GROUP_ID
)
{
return
views
;
}
return
views
.
filter
((
view
)
=>
`
${
view
.
title
||
''
}
${
view
.
description
||
''
}
${
view
.
subjectName
||
''
}
`
return
views
.
filter
((
view
)
=>
visibleGroupIds
.
has
(
view
.
groupId
));
},
[
activeGroupId
,
views
,
visibleGroupIds
]);
const
availableTags
=
useMemo
(()
=>
{
return
Array
.
from
(
new
Set
(
viewsInCurrentGroup
.
flatMap
((
view
)
=>
view
.
tags
||
[]).
filter
(
Boolean
)),
).
sort
((
left
,
right
)
=>
left
.
localeCompare
(
right
,
'zh-CN'
));
},
[
viewsInCurrentGroup
]);
const
filteredViews
=
useMemo
(()
=>
{
const
keyword
=
String
(
deferredViewKeyword
||
''
).
trim
().
toLowerCase
();
return
viewsInCurrentGroup
.
filter
((
view
)
=>
{
const
matchesKeyword
=
!
keyword
||
`
${
view
.
title
||
''
}
${
view
.
description
||
''
}
${
view
.
subjectName
||
''
}
${(
view
.
tags
||
[]).
join
(
' '
)}
`
.
toLowerCase
()
.
includes
(
keyword
),
);
},
[
viewKeyword
,
views
]);
.
includes
(
keyword
);
const
matchesTags
=
activeTagFilters
.
length
===
0
||
activeTagFilters
.
every
((
tag
)
=>
(
view
.
tags
||
[]).
includes
(
tag
));
return
matchesKeyword
&&
matchesTags
;
});
},
[
activeTagFilters
,
deferredViewKeyword
,
viewsInCurrentGroup
]);
useEffect
(()
=>
{
if
(
!
filteredViews
.
some
((
view
)
=>
view
.
id
===
activeViewId
))
{
startTransition
(()
=>
{
setActiveViewId
(
filteredViews
[
0
]?.
id
||
''
);
});
}
},
[
activeViewId
,
filteredViews
]);
const
activeView
=
useMemo
(
()
=>
views
.
find
((
item
)
=>
item
.
id
===
activeViewId
)
||
views
[
0
]
||
null
,
()
=>
views
.
find
((
item
)
=>
item
.
id
===
activeViewId
)
||
null
,
[
views
,
activeViewId
],
);
const
selectedSubject
=
useMemo
(
()
=>
subjects
.
find
((
subject
)
=>
subject
.
name
===
selectedSubjectName
)
||
subjectDefinition
||
null
,
[
subjectDefinition
,
selectedSubjectName
,
subjects
],
);
const
selectedRecords
=
useMemo
(
()
=>
records
.
filter
((
record
)
=>
selectedRecordIds
.
includes
(
record
.
id
)),
[
records
,
selectedRecordIds
],
);
useEffect
(()
=>
{
setActiveTagFilters
((
prev
)
=>
prev
.
filter
((
tag
)
=>
availableTags
.
includes
(
tag
)));
},
[
availableTags
]);
useEffect
(()
=>
{
if
(
!
activeView
)
{
setSelectedSubjectName
(
''
);
setSelectedRecordIds
([]);
setCurrentViewForwards
([]);
setCurrentViewForwardItems
([]);
return
;
}
...
...
@@ -348,7 +633,38 @@ export default function AnalysisViews() {
setFilterValues
(
activeView
.
defaultFilters
||
{});
setSelectedRecordIds
([]);
setActiveResultTab
(
'table'
);
},
[
activeView
,
selectedSubjectName
,
subjectDefinition
]);
},
[
activeView
,
selectedSubjectName
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
if
(
!
activeView
?.
id
)
{
setCurrentViewForwards
([]);
return
undefined
;
}
(
async
()
=>
{
try
{
const
[
forwardRecords
,
forwardItems
]
=
await
Promise
.
all
([
loadForwardRecords
({
viewId
:
activeView
.
id
}),
loadForwardItems
({
viewId
:
activeView
.
id
}),
]);
if
(
!
cancelled
)
{
setCurrentViewForwards
(
forwardRecords
);
setCurrentViewForwardItems
(
forwardItems
);
}
}
catch
{
if
(
!
cancelled
)
{
setCurrentViewForwards
([]);
setCurrentViewForwardItems
([]);
}
}
})();
return
()
=>
{
cancelled
=
true
;
};
},
[
activeView
?.
id
]);
async
function
runQuery
(
nextFilters
=
filterValues
)
{
if
(
!
selectedSubjectName
||
!
subjectDefinition
)
{
...
...
@@ -374,7 +690,76 @@ export default function AnalysisViews() {
useEffect
(()
=>
{
runQuery
(
activeView
?.
defaultFilters
||
{});
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
activeViewId
,
selectedSubjectName
,
subjectDefinition
?.
name
]);
},
[
activeView
?.
id
,
selectedSubjectName
,
subjectDefinition
?.
name
]);
const
forwardStatusIndex
=
useMemo
(
()
=>
buildForwardStatusIndex
(
currentViewForwards
),
[
currentViewForwards
],
);
const
forwardItemByRecordId
=
useMemo
(
()
=>
buildForwardItemIndex
(
currentViewForwardItems
),
[
currentViewForwardItems
],
);
const
manuallyHiddenRecordIdSet
=
useMemo
(
()
=>
new
Set
(
(
activeView
?.
hiddenRecordIds
||
[])
.
filter
(
Boolean
)
.
map
((
recordId
)
=>
String
(
recordId
)),
),
[
activeView
?.
hiddenRecordIds
],
);
const
visibleRecords
=
useMemo
(()
=>
{
return
records
.
filter
((
record
)
=>
{
const
recordKey
=
String
(
record
.
recordName
||
record
.
id
||
''
);
return
(
!
manuallyHiddenRecordIdSet
.
has
(
recordKey
)
&&
!
forwardStatusIndex
.
hiddenRecordIdSet
.
has
(
recordKey
)
);
});
},
[
forwardStatusIndex
.
hiddenRecordIdSet
,
manuallyHiddenRecordIdSet
,
records
]);
const
manuallyHiddenVisibleCount
=
useMemo
(
()
=>
records
.
filter
((
record
)
=>
manuallyHiddenRecordIdSet
.
has
(
String
(
record
.
recordName
||
record
.
id
||
''
)),
).
length
,
[
manuallyHiddenRecordIdSet
,
records
],
);
const
flowHiddenVisibleCount
=
useMemo
(
()
=>
records
.
filter
((
record
)
=>
{
const
recordKey
=
String
(
record
.
recordName
||
record
.
id
||
''
);
return
(
!
manuallyHiddenRecordIdSet
.
has
(
recordKey
)
&&
forwardStatusIndex
.
hiddenRecordIdSet
.
has
(
recordKey
)
);
}).
length
,
[
forwardStatusIndex
.
hiddenRecordIdSet
,
manuallyHiddenRecordIdSet
,
records
],
);
const
rowStatusByRecordId
=
useMemo
(()
=>
{
return
records
.
reduce
((
accumulator
,
record
)
=>
{
const
recordKey
=
String
(
record
.
recordName
||
record
.
id
||
''
);
const
forwardRecord
=
forwardStatusIndex
.
latestForwardByRecordId
.
get
(
recordKey
)
||
null
;
accumulator
[
recordKey
]
=
resolveForwardRowStatus
(
forwardRecord
);
return
accumulator
;
},
{});
},
[
forwardStatusIndex
.
latestForwardByRecordId
,
records
]);
const
selectedRecords
=
useMemo
(
()
=>
visibleRecords
.
filter
((
record
)
=>
selectedRecordIds
.
includes
(
record
.
id
)),
[
selectedRecordIds
,
visibleRecords
],
);
useEffect
(()
=>
{
const
visibleIds
=
new
Set
(
visibleRecords
.
map
((
record
)
=>
record
.
id
));
setSelectedRecordIds
((
prev
)
=>
prev
.
filter
((
id
)
=>
visibleIds
.
has
(
id
)));
},
[
visibleRecords
]);
const
visibleFilterFields
=
useMemo
(
()
=>
...
...
@@ -385,6 +770,7 @@ export default function AnalysisViews() {
.
filter
(
Boolean
),
[
activeView
,
subjectDefinition
],
);
const
visibleColumns
=
useMemo
(
()
=>
(
activeView
?.
columns
||
[])
...
...
@@ -395,10 +781,19 @@ export default function AnalysisViews() {
[
activeView
,
subjectDefinition
],
);
const
chartConfigs
=
activeView
?.
charts
||
[];
const
chartOptions
=
useMemo
(
()
=>
(
activeView
?.
charts
||
[]).
map
((
chart
)
=>
({
config
:
chart
,
option
:
buildChartOption
(
records
,
chart
,
subjectDefinition
?.
fields
||
[]),
})),
[
activeView
?.
charts
,
records
,
subjectDefinition
],
);
function
handleSelectView
(
view
)
{
startTransition
(()
=>
{
setActiveViewId
(
view
.
id
);
});
setSelectedSubjectName
(
view
.
subjectName
);
setSelectedRecordIds
([]);
setActiveResultTab
(
'table'
);
...
...
@@ -406,6 +801,22 @@ export default function AnalysisViews() {
setError
(
''
);
}
function
handleSelectGroup
(
groupId
)
{
startTransition
(()
=>
{
setActiveGroupId
(
groupId
);
});
setNotice
(
''
);
setError
(
''
);
}
function
toggleGroupExpanded
(
groupId
)
{
setExpandedGroupIds
((
prev
)
=>
prev
.
includes
(
groupId
)
?
prev
.
filter
((
id
)
=>
id
!==
groupId
)
:
[...
prev
,
groupId
],
);
}
async
function
handleSelectViewSubject
(
subject
)
{
const
nextSubjectName
=
subject
?.
name
||
''
;
const
nextDefinition
=
nextSubjectName
...
...
@@ -436,54 +847,75 @@ export default function AnalysisViews() {
...
buildEmptyViewForm
(),
...
draft
,
subjectName
:
selectedSubjectName
,
groupId
:
activeGroupId
===
ROOT_GROUP_ID
?
''
:
activeGroupId
,
groupName
:
activeGroupId
===
ROOT_GROUP_ID
?
''
:
groups
.
find
((
group
)
=>
group
.
id
===
activeGroupId
)?.
groupName
||
''
,
});
setIsViewDialogOpen
(
true
);
}
function
openEditDialog
()
{
if
(
!
active
View
)
{
function
openEditDialog
(
targetView
=
activeView
)
{
if
(
!
target
View
)
{
return
;
}
setViewForm
({
recordName
:
active
View
.
recordName
,
title
:
active
View
.
title
,
subjectName
:
active
View
.
subjectName
,
description
:
active
View
.
description
,
scope
:
active
View
.
scope
,
filterFields
:
active
View
.
filterFields
,
defaultFilters
:
active
View
.
defaultFilters
,
columns
:
active
View
.
columns
,
recordName
:
target
View
.
recordName
,
title
:
target
View
.
title
,
subjectName
:
target
View
.
subjectName
,
description
:
target
View
.
description
,
scope
:
target
View
.
scope
,
filterFields
:
target
View
.
filterFields
,
defaultFilters
:
target
View
.
defaultFilters
,
columns
:
target
View
.
columns
,
charts
:
activeView
.
charts
.
length
>
0
?
activeView
.
charts
:
[
buildChartDraft
(
subjectDefinition
?.
fields
||
[])],
targetView
.
charts
.
length
>
0
?
targetView
.
charts
:
[
buildChartDraft
(
targetView
.
subjectName
===
selectedSubjectName
?
subjectDefinition
?.
fields
||
[]
:
[],
),
],
groupId
:
targetView
.
groupId
||
''
,
groupName
:
groups
.
find
((
group
)
=>
group
.
id
===
targetView
.
groupId
)?.
groupName
||
targetView
.
groupName
||
''
,
tags
:
targetView
.
tags
||
[],
hiddenRecordIds
:
targetView
.
hiddenRecordIds
||
[],
});
setIsViewDialogOpen
(
true
);
}
async
function
handleSaveView
()
{
if
(
!
String
(
viewForm
.
title
||
''
).
trim
())
{
setError
(
'请填写视图名称。'
);
return
;
function
openCreateGroupDialog
(
parentGroupId
=
''
)
{
setGroupForm
({
...
buildEmptyGroupForm
(),
parentGroupId
,
});
setIsGroupDialogOpen
(
true
);
}
if
(
!
viewForm
.
subjectName
)
{
setError
(
'请先选择视图绑定的对象。'
);
function
openEditGroupDialog
(
)
{
if
(
!
selectedGroup
)
{
return
;
}
if
((
viewForm
.
columns
||
[]).
length
===
0
)
{
setError
(
'请至少选择一个结果列。'
);
return
;
setGroupForm
({
recordName
:
selectedGroup
.
recordName
,
groupName
:
selectedGroup
.
groupName
,
parentGroupId
:
selectedGroup
.
parentGroupId
||
''
,
});
setIsGroupDialogOpen
(
true
);
}
setSaving
(
true
);
try
{
const
saved
=
await
saveAnalysisView
(
viewForm
,
userInfo
);
function
mergeSavedViewIntoState
(
savedView
)
{
const
nextViews
=
[
saved
,
...
views
.
filter
((
item
)
=>
item
.
id
!==
saved
.
id
),
savedView
,
...
views
.
filter
((
item
)
=>
item
.
id
!==
savedView
.
id
),
].
sort
((
left
,
right
)
=>
String
(
right
.
updatedAt
||
right
.
createdAt
).
localeCompare
(
String
(
left
.
updatedAt
||
left
.
createdAt
),
...
...
@@ -494,14 +926,129 @@ export default function AnalysisViews() {
setSubjects
((
prev
)
=>
mergeSubjectsByName
([
{
name
:
saved
.
subjectName
,
title
:
selectedSubject
?.
title
||
saved
.
subjectName
,
name
:
savedView
.
subjectName
,
title
:
savedView
.
subjectName
,
},
...
prev
,
]),
);
setSelectedSubjectName
(
saved
.
subjectName
);
setActiveViewId
(
saved
.
id
);
setSelectedSubjectName
(
savedView
.
subjectName
);
setActiveGroupId
(
savedView
.
groupId
||
ROOT_GROUP_ID
);
setActiveViewId
(
savedView
.
id
);
return
nextViews
;
}
async
function
handleSaveGroup
()
{
if
(
!
String
(
groupForm
.
groupName
||
''
).
trim
())
{
setError
(
'请填写分组名称。'
);
return
;
}
if
(
groupForm
.
recordName
)
{
const
currentGroup
=
groups
.
find
((
group
)
=>
group
.
recordName
===
groupForm
.
recordName
);
const
descendantIds
=
collectDescendantIds
(
groups
,
currentGroup
?.
id
);
if
(
currentGroup
?.
id
&&
(
groupForm
.
parentGroupId
===
currentGroup
.
id
||
descendantIds
.
has
(
groupForm
.
parentGroupId
))
)
{
setError
(
'父分组不能选择当前分组或其子分组。'
);
return
;
}
}
setSaving
(
true
);
try
{
const
saved
=
await
saveAnalysisGroup
(
groupForm
,
userInfo
);
const
nextGroups
=
[
saved
,
...
groups
.
filter
((
item
)
=>
item
.
id
!==
saved
.
id
),
];
setGroups
(
nextGroups
);
setExpandedGroupIds
((
prev
)
=>
Array
.
from
(
new
Set
([...
prev
,
saved
.
id
,
saved
.
parentGroupId
].
filter
(
Boolean
))),
);
setActiveGroupId
(
saved
.
id
);
setIsGroupDialogOpen
(
false
);
setNotice
(
`分组“
${
saved
.
groupName
}
”已保存。`
);
setError
(
''
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'分组保存失败'
);
}
finally
{
setSaving
(
false
);
}
}
function
openDeleteGroupDialog
()
{
if
(
!
selectedGroup
)
{
return
;
}
const
hasChildren
=
groups
.
some
(
(
group
)
=>
group
.
parentGroupId
===
selectedGroup
.
id
,
);
const
hasViews
=
views
.
some
((
view
)
=>
view
.
groupId
===
selectedGroup
.
id
);
if
(
hasChildren
||
hasViews
)
{
setError
(
'请先迁移子分组或视图,再删除当前分组。'
);
return
;
}
setGroupPendingDelete
(
selectedGroup
);
setIsDeleteGroupDialogOpen
(
true
);
}
async
function
handleDeleteGroup
()
{
if
(
!
groupPendingDelete
)
{
return
;
}
setSaving
(
true
);
try
{
await
deleteAnalysisGroup
(
groupPendingDelete
);
setGroups
((
prev
)
=>
prev
.
filter
((
item
)
=>
item
.
id
!==
groupPendingDelete
.
id
));
setExpandedGroupIds
((
prev
)
=>
prev
.
filter
((
id
)
=>
id
!==
groupPendingDelete
.
id
));
setActiveGroupId
(
ROOT_GROUP_ID
);
setIsDeleteGroupDialogOpen
(
false
);
setGroupPendingDelete
(
null
);
setNotice
(
`已删除分组“
${
groupPendingDelete
.
groupName
}
”。`
);
setError
(
''
);
}
catch
(
deleteError
)
{
setError
(
deleteError
?.
message
||
'分组删除失败'
);
}
finally
{
setSaving
(
false
);
}
}
async
function
handleSaveView
()
{
if
(
!
String
(
viewForm
.
title
||
''
).
trim
())
{
setError
(
'请填写视图名称。'
);
return
;
}
if
(
!
viewForm
.
subjectName
)
{
setError
(
'请先选择视图绑定的对象。'
);
return
;
}
if
((
viewForm
.
columns
||
[]).
length
===
0
)
{
setError
(
'请至少选择一个结果列。'
);
return
;
}
const
selectedViewGroup
=
groups
.
find
((
group
)
=>
group
.
id
===
viewForm
.
groupId
);
setSaving
(
true
);
try
{
const
saved
=
await
saveAnalysisView
(
{
...
viewForm
,
groupName
:
selectedViewGroup
?.
groupName
||
''
,
},
userInfo
,
);
mergeSavedViewIntoState
(
saved
);
setActiveResultTab
(
'table'
);
setNotice
(
'分析视图已保存。'
);
setError
(
''
);
...
...
@@ -513,39 +1060,141 @@ export default function AnalysisViews() {
}
}
async
function
handleDeleteView
()
{
if
(
!
active
View
)
{
async
function
handleDeleteView
(
targetView
=
activeView
)
{
if
(
!
target
View
)
{
return
;
}
const
confirmed
=
window
.
confirm
(
`确认删除视图“
${
activeView
.
title
}
”吗?`
,
);
const
confirmed
=
window
.
confirm
(
`确认删除视图“
${
targetView
.
title
}
”吗?`
);
if
(
!
confirmed
)
{
return
;
}
await
deleteAnalysisView
(
active
View
);
const
nextViews
=
views
.
filter
((
item
)
=>
item
.
id
!==
active
View
.
id
);
await
deleteAnalysisView
(
target
View
);
const
nextViews
=
views
.
filter
((
item
)
=>
item
.
id
!==
target
View
.
id
);
setViews
(
nextViews
);
if
(
activeView
?.
id
===
targetView
.
id
)
{
setActiveViewId
(
nextViews
[
0
]?.
id
||
''
);
setNotice
(
`已删除视图“
${
activeView
.
title
}
”。`
);
}
setNotice
(
`已删除视图“
${
targetView
.
title
}
”。`
);
}
function
handleToggleRow
(
record
,
checked
)
{
setSelectedRecordIds
((
prev
)
=>
checked
?
[...
new
Set
([...
prev
,
record
.
id
])]
:
prev
.
filter
((
id
)
=>
id
!==
record
.
id
),
checked
?
[...
new
Set
([...
prev
,
record
.
id
])]
:
prev
.
filter
((
id
)
=>
id
!==
record
.
id
),
);
}
function
handleToggleAll
(
checked
)
{
if
(
checked
)
{
setSelectedRecordIds
(
records
.
map
((
record
)
=>
record
.
id
));
setSelectedRecordIds
(
visibleRecords
.
map
((
record
)
=>
record
.
id
));
return
;
}
setSelectedRecordIds
([]);
}
async
function
handleHideSelectedRecords
()
{
if
(
!
activeView
)
{
return
;
}
if
(
selectedRecords
.
length
===
0
)
{
setError
(
'请先勾选要隐藏的记录。'
);
return
;
}
const
nextHiddenRecordIds
=
Array
.
from
(
new
Set
([
...(
activeView
.
hiddenRecordIds
||
[]),
...
selectedRecords
.
map
((
record
)
=>
String
(
record
.
recordName
||
record
.
id
||
''
)),
]),
);
setSaving
(
true
);
try
{
const
saved
=
await
saveAnalysisView
(
{
...
activeView
,
hiddenRecordIds
:
nextHiddenRecordIds
,
},
userInfo
,
);
mergeSavedViewIntoState
(
saved
);
setSelectedRecordIds
([]);
setNotice
(
`已在当前视图中隐藏
${
selectedRecords
.
length
}
条记录。`
);
setError
(
''
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'隐藏记录失败'
);
}
finally
{
setSaving
(
false
);
}
}
async
function
openHiddenRecordsDialog
()
{
if
(
!
activeView
?.
hiddenRecordIds
?.
length
)
{
return
;
}
setIsHiddenRecordsDialogOpen
(
true
);
setHiddenRecordsLoading
(
true
);
setSelectedHiddenRecordIds
([]);
try
{
const
result
=
await
loadAnalysisRecordsByIds
(
activeView
.
subjectName
,
activeView
.
hiddenRecordIds
,
);
setHiddenRecords
(
result
.
records
);
setError
(
''
);
}
catch
(
loadError
)
{
setHiddenRecords
([]);
setError
(
loadError
?.
message
||
'已隐藏记录加载失败'
);
}
finally
{
setHiddenRecordsLoading
(
false
);
}
}
async
function
handleRestoreHiddenRecords
()
{
if
(
!
activeView
)
{
return
;
}
if
(
selectedHiddenRecordIds
.
length
===
0
)
{
setError
(
'请先勾选要恢复显示的记录。'
);
return
;
}
const
restoreIdSet
=
new
Set
(
selectedHiddenRecordIds
.
map
((
id
)
=>
String
(
id
)));
const
nextHiddenRecordIds
=
(
activeView
.
hiddenRecordIds
||
[]).
filter
(
(
recordId
)
=>
!
restoreIdSet
.
has
(
String
(
recordId
)),
);
setSaving
(
true
);
try
{
const
saved
=
await
saveAnalysisView
(
{
...
activeView
,
hiddenRecordIds
:
nextHiddenRecordIds
,
},
userInfo
,
);
mergeSavedViewIntoState
(
saved
);
setHiddenRecords
((
prev
)
=>
prev
.
filter
((
record
)
=>
!
restoreIdSet
.
has
(
String
(
record
.
recordName
||
record
.
id
||
''
))),
);
setSelectedHiddenRecordIds
([]);
setIsHiddenRecordsDialogOpen
(
false
);
setNotice
(
`已恢复
${
restoreIdSet
.
size
}
条记录在当前视图中的显示。`
);
setError
(
''
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'恢复显示失败'
);
}
finally
{
setSaving
(
false
);
}
}
async
function
handleSubmitForward
()
{
...
...
@@ -559,6 +1208,16 @@ export default function AnalysisViews() {
return
;
}
const
excludedIdSet
=
new
Set
(
forwardForm
.
excludedRecordIds
||
[]);
const
deliverRecords
=
selectedRecords
.
filter
(
(
record
)
=>
!
excludedIdSet
.
has
(
record
.
recordName
||
record
.
id
),
);
if
(
deliverRecords
.
length
===
0
)
{
setError
(
'请至少保留一条需要下发的记录。'
);
return
;
}
if
(
forwardForm
.
recipientIds
.
length
===
0
)
{
setError
(
'请至少添加一位接收人。'
);
return
;
...
...
@@ -576,12 +1235,15 @@ export default function AnalysisViews() {
setSaving
(
true
);
try
{
await
saveForwardRecord
(
const
savedForwardRecord
=
await
saveForwardRecord
(
{
viewId
:
activeView
.
id
,
viewName
:
activeView
.
title
,
subjectName
:
activeView
.
subjectName
,
selectedRecordIds
:
selectedRecords
.
map
((
record
)
=>
record
.
recordName
||
record
.
id
),
selectedRecordIds
:
deliverRecords
.
map
((
record
)
=>
record
.
recordName
||
record
.
id
),
excludedRecordIds
:
selectedRecords
.
filter
((
record
)
=>
excludedIdSet
.
has
(
record
.
recordName
||
record
.
id
))
.
map
((
record
)
=>
record
.
recordName
||
record
.
id
),
recipientIds
:
forwardForm
.
recipientIds
.
map
((
item
)
=>
item
.
id
),
recipientNames
:
forwardForm
.
recipientIds
.
map
((
item
)
=>
item
.
label
),
note
:
forwardForm
.
note
,
...
...
@@ -594,7 +1256,15 @@ export default function AnalysisViews() {
},
userInfo
,
);
setNotice
(
`已提交
${
selectedRecords
.
length
}
条记录的转发审批。`
);
await
ensureForwardRecordItems
(
savedForwardRecord
,
deliverRecords
,
userInfo
);
const
[
refreshedForwards
,
refreshedForwardItems
]
=
await
Promise
.
all
([
loadForwardRecords
({
viewId
:
activeView
.
id
}),
loadForwardItems
({
viewId
:
activeView
.
id
}),
]);
setCurrentViewForwards
(
refreshedForwards
);
setCurrentViewForwardItems
(
refreshedForwardItems
);
setNotice
(
`已提交
${
deliverRecords
.
length
}
条记录的转发审批。`
);
setIsForwardDialogOpen
(
false
);
setForwardForm
(
createEmptyForwardDraft
());
setSelectedRecordIds
([]);
...
...
@@ -606,14 +1276,11 @@ export default function AnalysisViews() {
}
}
const
chartOptions
=
useMemo
(
()
=>
chartConfigs
.
map
((
chart
)
=>
({
config
:
chart
,
option
:
buildChartOption
(
records
,
chart
,
subjectDefinition
?.
fields
||
[]),
})),
[
chartConfigs
,
records
,
subjectDefinition
],
);
const
activeGroupLabel
=
activeGroupId
===
ROOT_GROUP_ID
?
'全部视图'
:
selectedGroup
?.
groupName
||
'未命名分组'
;
const
hiddenCount
=
manuallyHiddenVisibleCount
+
flowHiddenVisibleCount
;
return
(
<
div
className=
"space-y-6"
>
...
...
@@ -621,102 +1288,262 @@ export default function AnalysisViews() {
{
!
loading
&&
error
?
<
NoticeBanner
tone=
"error"
>
{
error
}
</
NoticeBanner
>
:
null
}
{
!
error
&&
notice
?
<
NoticeBanner
tone=
"success"
>
{
notice
}
</
NoticeBanner
>
:
null
}
<
div
className=
"grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]"
>
<
Panel
className=
"p-4 sm:p-5"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
className=
"grid gap-6 xl:grid-cols-[19rem_minmax(0,1fr)] xl:items-start"
>
<
div
className=
"xl:sticky xl:top-0"
>
<
Panel
className=
"overflow-hidden xl:flex xl:max-h-[calc(100dvh-9rem)] xl:flex-col"
>
<
div
className=
"border-b border-zinc-200/70 bg-[linear-gradient(180deg,rgba(255,241,242,0.78),rgba(255,255,255,0.94))] p-5"
>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
>
<
div
className=
"text-base font-semibold text-zinc-950"
>
全部视图
</
div
>
<
div
className=
"text-base font-semibold text-zinc-950"
>
视图分组
</
div
>
<
div
className=
"mt-1 text-xs text-zinc-500"
>
直接选择视图查看右侧数据
。
像网盘文件夹一样维护无限级分组
。
</
div
>
</
div
>
<
Button
outline
onClick=
{
()
=>
openCreateGroupDialog
(
activeGroupId
===
ROOT_GROUP_ID
?
''
:
activeGroupId
)
}
>
<
FolderPlusIcon
className=
"size-4"
/>
新建
</
Button
>
</
div
>
<
div
className=
"mt-4 flex flex-wrap gap-2"
>
<
Button
outline
onClick=
{
openEditGroupDialog
}
disabled=
{
activeGroupId
===
ROOT_GROUP_ID
}
>
<
PencilSquareIcon
className=
"size-4"
/>
重命名
</
Button
>
<
Button
outline
onClick=
{
openDeleteGroupDialog
}
disabled=
{
activeGroupId
===
ROOT_GROUP_ID
}
>
<
TrashIcon
className=
"size-4"
/>
删除
</
Button
>
</
div
>
</
div
>
<
div
className=
"min-h-[20rem] overflow-y-auto p-4 xl:min-h-0 xl:flex-1"
>
<
button
type=
"button"
onClick=
{
()
=>
handleSelectGroup
(
ROOT_GROUP_ID
)
}
className=
{
`mb-3 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium transition ${
activeGroupId === ROOT_GROUP_ID
? 'bg-zinc-950 text-white shadow-sm'
: 'bg-zinc-50 text-zinc-700 hover:bg-zinc-100'
}`
}
>
<
FolderIcon
className=
"size-4"
/>
<
span
>
全部视图
</
span
>
</
button
>
<
div
className=
"space-y-1"
>
{
groupTree
.
length
>
0
?
(
groupTree
.
map
((
group
)
=>
(
<
GroupTreeItem
key=
{
group
.
id
}
group=
{
group
}
activeGroupId=
{
activeGroupId
}
expandedGroupIds=
{
expandedGroupIds
}
onToggle=
{
toggleGroupExpanded
}
onSelect=
{
handleSelectGroup
}
/>
))
)
:
(
<
EmptyCollection
title=
"还没有分组"
description=
"先创建一个分组,再把视图归档进去。"
/>
)
}
</
div
>
</
div
>
</
Panel
>
</
div
>
<
div
className=
"min-w-0 space-y-6"
>
<
Panel
className=
"overflow-hidden"
>
<
div
className=
"border-b border-zinc-200/70 bg-[linear-gradient(180deg,rgba(255,250,240,0.9),rgba(255,255,255,0.96))] p-5 sm:p-6"
>
<
SectionHeader
title=
{
activeGroupLabel
}
description=
{
activeGroupId
===
ROOT_GROUP_ID
?
'这里汇总全部视图,你可以按名称、对象或标签快速筛选。'
:
'右侧只展示当前分组及其子分组下的视图,方便在同一业务主题里连续切换。'
}
action=
{
<
div
className=
"flex flex-wrap gap-2"
>
<
Tag
>
{
viewsInCurrentGroup
.
length
}
个视图
</
Tag
>
{
countingViews
?
<
Tag
tone=
"amber"
>
正在统计数据量
</
Tag
>
:
null
}
<
Button
color=
"blue"
onClick=
{
openCreateDialog
}
>
<
PlusIcon
className=
"size-4"
/>
新建
新建视图
</
Button
>
</
div
>
<
div
className=
"mb-3 flex justify-end"
>
<
Tag
>
{
views
.
length
}
个视图
</
Tag
>
}
/>
<
div
className=
"grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start"
>
<
SearchInput
value=
{
viewKeyword
}
onChange=
{
setViewKeyword
}
placeholder=
"搜索视图名称、说明、对象或标签"
/>
<
div
className=
"flex flex-wrap gap-2"
>
{
availableTags
.
map
((
tag
)
=>
{
const
active
=
activeTagFilters
.
includes
(
tag
);
return
(
<
button
key=
{
tag
}
type=
"button"
aria
-
pressed=
{
active
}
onClick=
{
()
=>
startTransition
(()
=>
{
setActiveTagFilters
((
prev
)
=>
prev
.
includes
(
tag
)
?
prev
.
filter
((
item
)
=>
item
!==
tag
)
:
[...
prev
,
tag
],
);
})
}
className=
{
`rounded-full border px-3 py-1 text-xs font-medium transition ${
active
? 'border-rose-300 bg-rose-50 text-rose-700'
: 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300'
}`
}
>
{
tag
}
</
button
>
);
})
}
</
div
>
</
div
>
<
Input
value=
{
viewKeyword
}
onChange=
{
(
event
)
=>
setViewKeyword
(
event
.
target
.
value
)
}
placeholder=
"搜索视图名称、说明或对象"
/>
<
div
className=
"mt-4 max-h-[calc(100vh-10rem)] min-h-[32rem] space-y-3 overflow-y-auto pr-1"
>
</
div
>
<
div
className=
"max-h-[26rem] overflow-y-auto p-5 sm:p-6"
>
{
filteredViews
.
length
>
0
?
(
filteredViews
.
map
((
view
)
=>
(
<
button
<
div
className=
"relative overflow-x-auto rounded-[22px] border border-zinc-200 bg-white"
>
<
div
className=
"grid min-w-[72rem] grid-cols-[minmax(18rem,1.8fr)_8rem_8rem_minmax(9rem,0.9fr)_5rem_5rem_5rem_5rem_6.5rem] gap-4 border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-medium text-zinc-500"
>
<
div
>
视图
</
div
>
<
div
>
对象
</
div
>
<
div
>
分组
</
div
>
<
div
>
标签
</
div
>
<
div
className=
"text-right"
>
数据量
</
div
>
<
div
className=
"text-right"
>
结果列
</
div
>
<
div
className=
"text-right"
>
筛选
</
div
>
<
div
className=
"text-right"
>
图表
</
div
>
<
div
className=
"sticky right-0 -my-3 flex items-center justify-end bg-zinc-50 py-3 pl-4 shadow-[-16px_0_24px_rgba(255,255,255,0.92)]"
>
操作
</
div
>
</
div
>
<
div
className=
"divide-y divide-zinc-100"
>
{
filteredViews
.
map
((
view
)
=>
{
const
groupName
=
groups
.
find
((
group
)
=>
group
.
id
===
view
.
groupId
)?.
groupName
||
view
.
groupName
||
'未分组'
;
const
active
=
activeView
?.
id
===
view
.
id
;
return
(
<
div
key=
{
view
.
id
}
type=
"button"
role=
"button"
tabIndex=
{
0
}
onClick=
{
()
=>
handleSelectView
(
view
)
}
className=
{
`w-full rounded-[24px] border px-4 py-4 text-left transition ${
activeView?.id === view.id
? 'border-sky-300 bg-sky-50 shadow-sm ring-2 ring-sky-100'
: 'border-zinc-200 bg-white hover:border-zinc-300 hover:bg-zinc-50'
onKeyDown=
{
(
event
)
=>
{
if
(
event
.
key
===
'Enter'
||
event
.
key
===
' '
)
{
event
.
preventDefault
();
handleSelectView
(
view
);
}
}
}
className=
{
`group grid min-w-[72rem] grid-cols-[minmax(18rem,1.8fr)_8rem_8rem_minmax(9rem,0.9fr)_5rem_5rem_5rem_5rem_6.5rem] items-center gap-4 border-l-4 px-4 py-3 text-left transition ${
active
? 'border-l-rose-500 bg-rose-50/80'
: 'border-l-transparent hover:bg-zinc-50'
}`
}
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-sm font-semibold text-zinc-950"
>
<
div
className=
{
`truncate text-sm font-semibold ${
active ? 'text-rose-700' : 'text-zinc-950'
}`
}
>
{
view
.
title
}
</
div
>
<
div
className=
"mt-1 line-clamp-2 text-xs leading-5
text-zinc-500"
>
<
div
className=
"mt-1 line-clamp-2 text-sm leading-6
text-zinc-500"
>
{
view
.
description
||
'未填写视图说明'
}
</
div
>
</
div
>
<
Tag
>
{
view
.
subjectName
}
</
Tag
>
<
div
>
<
Tag
tone=
"sky"
>
{
view
.
subjectName
}
</
Tag
>
</
div
>
<
div
className=
"truncate text-sm text-zinc-600"
>
{
groupName
}
</
div
>
<
div
className=
"flex min-w-0 flex-wrap gap-1.5"
>
{
(
view
.
tags
||
[]).
length
>
0
?
(
(
view
.
tags
||
[]).
map
((
tag
)
=>
(
<
Tag
key=
{
tag
}
tone=
"rose"
>
{
tag
}
</
Tag
>
))
)
:
(
<
span
className=
"text-sm text-zinc-400"
>
-
</
span
>
)
}
</
div
>
<
div
className=
"text-right text-sm font-semibold text-zinc-950"
>
{
viewCounts
[
view
.
id
]
??
'-'
}
</
div
>
<
div
className=
"text-right text-sm text-zinc-600"
>
{
view
.
columns
.
length
}
</
div
>
<
div
className=
"text-right text-sm text-zinc-600"
>
{
view
.
filterFields
.
length
}
</
div
>
<
div
className=
"text-right text-sm text-zinc-600"
>
{
view
.
charts
.
length
}
</
div
>
<
div
className=
{
`sticky right-0 -my-3 flex justify-end gap-2 py-3 pl-4 shadow-[-16px_0_24px_rgba(255,255,255,0.86)] ${
active ? 'bg-rose-50' : 'bg-white group-hover:bg-zinc-50'
}`
}
>
<
Button
outline
aria
-
label=
{
`编辑视图 ${view.title}`
}
title=
"编辑"
className=
"h-11 w-11 px-0"
onClick=
{
(
event
)
=>
{
event
.
stopPropagation
();
openEditDialog
(
view
);
}
}
>
<
ChartBarIcon
className=
"size-4"
/>
</
Button
>
<
Button
outline
aria
-
label=
{
`删除视图 ${view.title}`
}
title=
"删除"
className=
"h-11 w-11 px-0"
onClick=
{
(
event
)
=>
{
event
.
stopPropagation
();
handleDeleteView
(
view
);
}
}
>
<
TrashIcon
className=
"size-4"
/>
</
Button
>
</
div
>
</
div
>
);
})
}
</
div
>
<
div
className=
"mt-3 flex flex-wrap gap-2 text-xs text-zinc-500"
>
<
span
className=
"rounded-full bg-zinc-100 px-2.5 py-1"
>
{
view
.
columns
.
length
}
列
</
span
>
<
span
className=
"rounded-full bg-zinc-100 px-2.5 py-1"
>
{
view
.
filterFields
.
length
}
筛选
</
span
>
<
span
className=
"rounded-full bg-zinc-100 px-2.5 py-1"
>
{
view
.
charts
.
length
}
图表
</
span
>
</
div
>
</
button
>
))
)
:
(
<
EmptyCollection
title=
"还没有匹配的分析
视图"
description=
"可以调整关键词,或新建一个分析视图后再查看
。"
title=
"当前分组下还没有匹配的
视图"
description=
"可以调整关键词或标签,也可以先新建一个分析视图
。"
/>
)
}
</
div
>
</
Panel
>
<
div
className=
"min-w-0 space-y-5"
>
{
activeView
&&
subjectDefinition
?
(
<>
<
Panel
className=
"p-5 sm:p-6"
>
<
SectionHeader
title=
{
activeView
.
title
}
description=
{
activeView
.
description
||
'当前视图未填写说明,可在编辑视图中补充。'
}
action=
{
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Tag
tone=
"sky"
>
{
activeView
.
subjectName
}
</
Tag
>
<
Tag
>
{
subjectDefinition
?.
fields
?.
length
||
0
}
个字段
</
Tag
>
{
querying
?
<
Tag
tone=
"sky"
>
正在刷新...
</
Tag
>
:
null
}
<
Button
outline
onClick=
{
openEditDialog
}
>
<
ChartBarIcon
className=
"size-4"
/>
编辑
</
Button
>
<
Button
outline
onClick=
{
handleDeleteView
}
>
<
TrashIcon
className=
"size-4"
/>
删除
</
Button
>
</
div
>
}
/>
<
DynamicFilterGrid
fields=
{
visibleFilterFields
}
values=
{
filterValues
}
...
...
@@ -727,7 +1554,6 @@ export default function AnalysisViews() {
}
}
onSubmit=
{
()
=>
runQuery
(
filterValues
)
}
/>
</
Panel
>
<
Panel
className=
{
`${
...
...
@@ -742,13 +1568,19 @@ export default function AnalysisViews() {
结果区
</
div
>
<
div
className=
"mt-1 text-sm leading-6 text-zinc-500"
>
当前命中
{
total
}
条记录,表格和图表通过 Tab 切换。
当前命中
{
total
}
条记录,当前展示
{
visibleRecords
.
length
}
条,
隐藏
{
hiddenCount
}
条。
{
hiddenCount
>
0
?
(
<
span
>
其中手动隐藏
{
manuallyHiddenVisibleCount
}
条,流程临时隐藏
{
flowHiddenVisibleCount
}
条。
</
span
>
)
:
null
}
</
div
>
</
div
>
<
div
className=
"flex flex-wrap gap-2"
>
<
div
className=
"inline-flex rounded-full border border-zinc-200 bg-zinc-50 p-1"
>
{
[
{
key
:
'table'
,
label
:
'表格'
,
hint
:
`${
r
ecords.length} 条`
},
{
key
:
'table'
,
label
:
'表格'
,
hint
:
`${
visibleR
ecords.length} 条`
},
{
key
:
'charts'
,
label
:
'图表'
,
hint
:
`${chartOptions.length} 个`
},
].
map
((
tab
)
=>
(
<
button
...
...
@@ -763,17 +1595,13 @@ export default function AnalysisViews() {
}`
}
>
{
tab
.
label
}
<
span
className=
"ml-2 text-xs text-zinc-400"
>
{
tab
.
hint
}
</
span
>
<
span
className=
"ml-2 text-xs text-zinc-400"
>
{
tab
.
hint
}
</
span
>
</
button
>
))
}
</
div
>
<
Button
outline
onClick=
{
()
=>
setIsResultFullscreen
((
current
)
=>
!
current
)
}
onClick=
{
()
=>
setIsResultFullscreen
((
current
)
=>
!
current
)
}
>
{
isResultFullscreen
?
(
<
ArrowsPointingInIcon
className=
"size-4"
/>
...
...
@@ -782,39 +1610,68 @@ export default function AnalysisViews() {
)
}
{
isResultFullscreen
?
'缩小'
:
'全屏'
}
</
Button
>
<
Button
outline
onClick=
{
openHiddenRecordsDialog
}
disabled=
{
!
activeView
.
hiddenRecordIds
?.
length
}
>
管理已隐藏
{
activeView
.
hiddenRecordIds
?.
length
||
0
}
条
</
Button
>
</
div
>
</
div
>
{
activeResultTab
===
'table'
?
(
<>
<
div
className=
"mb-4 flex flex-col gap-3 rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<
div
className=
"text-sm leading-6 text-zinc-600"
>
可以把当前视图里不想再看到的记录直接隐藏。隐藏后再次进入这个视图也不会显示;后续可在“管理已隐藏”里恢复。转发出去的记录不会因为转发动作自动消失。
</
div
>
<
div
className=
"flex flex-col gap-2 sm:flex-row"
>
<
Button
outline
onClick=
{
handleHideSelectedRecords
}
disabled=
{
saving
||
selectedRecordIds
.
length
===
0
}
>
隐藏所选
</
Button
>
</
div
>
</
div
>
<
SelectionToolbar
count=
{
selectedRecordIds
.
length
}
onForward=
{
()
=>
{
setForwardForm
(
createEmptyForwardDraft
());
setIsForwardDialogOpen
(
true
);
setError
(
''
);
}
}
onClear=
{
()
=>
setSelectedRecordIds
([])
}
/>
{
r
ecords
.
length
>
0
?
(
{
visibleR
ecords
.
length
>
0
?
(
<
div
className=
{
`overflow-auto rounded-[22px] border border-zinc-200 bg-white ${
isResultFullscreen
? 'h-[calc(100vh-16rem)]'
: 'h-[32rem]'
isResultFullscreen ? 'h-[calc(100vh-16rem)]' : 'h-[32rem]'
}`
}
>
<
RecordsResultTable
records=
{
r
ecords
}
records=
{
visibleR
ecords
}
columns=
{
visibleColumns
}
selectedIds=
{
selectedRecordIds
}
rowStatusByRecordId=
{
rowStatusByRecordId
}
forwardItemByRecordId=
{
forwardItemByRecordId
}
onOpenForwardItem=
{
(
record
,
forwardItem
)
=>
setForwardItemDetail
({
record
,
forwardItem
})
}
onToggleRow=
{
handleToggleRow
}
onToggleAll=
{
handleToggleAll
}
/>
</
div
>
)
:
(
<
EmptyCollection
title=
"当前筛选条件下没有命中记录"
description=
"可以调整筛选条件,或者先确认该对象是否已经同步到 Record 数据中。"
title=
{
hiddenCount
>
0
?
'当前记录已被隐藏'
:
'当前筛选条件下没有命中记录'
}
description=
{
hiddenCount
>
0
?
`当前筛选结果里有 ${manuallyHiddenVisibleCount} 条被手动隐藏,${flowHiddenVisibleCount} 条因流程中的“不下发”暂时隐藏。可以通过“管理已隐藏”恢复手动隐藏数据。`
:
'可以调整筛选条件,或者先确认该对象是否已经同步到 Record 数据中。'
}
/>
)
}
</>
...
...
@@ -845,23 +1702,260 @@ export default function AnalysisViews() {
</>
)
:
(
<
EmptyCollection
title=
"先创建或选择一个分析视图"
description=
"左侧会直接展示全部视图,选择后右侧自动加载该视图绑定对象的数据。"
title=
"先在当前分组里选择一个分析视图"
description=
"左侧只负责切换分组,右侧卡片区负责选择具体视图。"
/>
)
}
</
div
>
</
div
>
<
Dialog
open=
{
Boolean
(
forwardItemDetail
)
}
onClose=
{
(
nextOpen
)
=>
{
if
(
!
nextOpen
)
{
setForwardItemDetail
(
null
);
}
}
}
size=
"2xl"
>
<
DialogTitle
>
回填详情
</
DialogTitle
>
<
DialogDescription
>
这里展示当前记录最近一次转发回填的属实情况、整改方案和预计完成时间。
</
DialogDescription
>
<
DialogBody
>
{
forwardItemDetail
?
(
<
div
className=
"space-y-4"
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-4"
>
<
div
className=
"text-sm font-medium text-zinc-500"
>
源记录
</
div
>
<
div
className=
"mt-2 text-base font-semibold text-zinc-950"
>
{
forwardItemDetail
.
record
?.
title
||
forwardItemDetail
.
forwardItem
?.
sourceRecordTitle
||
'-'
}
</
div
>
<
div
className=
"mt-1 text-xs text-zinc-500"
>
记录 ID:
{
forwardItemDetail
.
record
?.
recordName
||
forwardItemDetail
.
record
?.
id
||
forwardItemDetail
.
forwardItem
?.
sourceRecordId
||
'-'
}
</
div
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-white px-4 py-4"
>
<
div
className=
"text-sm font-medium text-zinc-500"
>
是否属实
</
div
>
<
div
className=
"mt-2 text-base font-semibold text-zinc-950"
>
{
getTruthStatusLabel
(
forwardItemDetail
.
forwardItem
?.
truthStatus
)
}
</
div
>
</
div
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-white px-4 py-4"
>
<
div
className=
"text-sm font-medium text-zinc-500"
>
预计整改完成时间
</
div
>
<
div
className=
"mt-2 text-base font-semibold text-zinc-950"
>
{
formatDateTime
(
forwardItemDetail
.
forwardItem
?.
expectedRectificationCompletedAt
,
)
}
</
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-500"
>
整改方案
</
div
>
<
div
className=
"mt-3 whitespace-pre-wrap text-sm leading-6 text-zinc-700"
>
{
forwardItemDetail
.
forwardItem
?.
rectificationPlan
||
'-'
}
</
div
>
</
div
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm leading-6 text-zinc-600"
>
填写人:
{
forwardItemDetail
.
forwardItem
?.
filledByName
||
'-'
}
<
br
/>
填写时间:
{
formatDateTime
(
forwardItemDetail
.
forwardItem
?.
filledAt
)
}
<
br
/>
来源批次:
{
forwardItemDetail
.
forwardItem
?.
forwardRecordTitle
||
'-'
}
</
div
>
</
div
>
)
:
null
}
</
DialogBody
>
<
DialogActions
>
<
Button
outline
onClick=
{
()
=>
setForwardItemDetail
(
null
)
}
>
关闭
</
Button
>
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
isHiddenRecordsDialogOpen
}
onClose=
{
(
nextOpen
)
=>
{
setIsHiddenRecordsDialogOpen
(
nextOpen
);
if
(
!
nextOpen
)
{
setSelectedHiddenRecordIds
([]);
}
}
}
size=
"4xl"
>
<
DialogTitle
>
管理已隐藏记录
</
DialogTitle
>
<
DialogDescription
>
这里管理当前视图里被手动隐藏的记录。恢复后,这些记录会重新出现在本视图的结果表格中。
</
DialogDescription
>
<
DialogBody
>
{
hiddenRecordsLoading
?
(
<
NoticeBanner
>
正在加载已隐藏记录...
</
NoticeBanner
>
)
:
hiddenRecords
.
length
>
0
?
(
<
div
className=
"space-y-3"
>
{
hiddenRecords
.
map
((
record
)
=>
{
const
recordKey
=
String
(
record
.
recordName
||
record
.
id
||
''
);
const
checked
=
selectedHiddenRecordIds
.
includes
(
recordKey
);
return
(
<
CheckboxField
key=
{
recordKey
}
className=
"rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<
Checkbox
color=
"sky"
checked=
{
checked
}
onChange=
{
(
nextChecked
)
=>
setSelectedHiddenRecordIds
((
prev
)
=>
nextChecked
?
[...
new
Set
([...
prev
,
recordKey
])]
:
prev
.
filter
((
id
)
=>
id
!==
recordKey
),
)
}
/>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-sm font-medium text-zinc-800"
>
{
record
.
title
||
record
.
recordName
||
record
.
id
}
</
div
>
<
div
className=
"mt-1 text-xs text-zinc-500"
>
{
record
.
missing
?
`该记录当前无法读取,将按 ID ${recordKey} 恢复显示。`
:
`记录 ID:${recordKey}`
}
</
div
>
</
div
>
</
CheckboxField
>
);
})
}
</
div
>
)
:
(
<
EmptyCollection
title=
"当前视图还没有手动隐藏记录"
description=
"后续在结果区勾选记录并点击“隐藏所选”后,会出现在这里。"
/>
)
}
</
DialogBody
>
<
DialogActions
>
<
Button
outline
onClick=
{
()
=>
{
setIsHiddenRecordsDialogOpen
(
false
);
setSelectedHiddenRecordIds
([]);
}
}
>
关闭
</
Button
>
<
Button
color=
"blue"
onClick=
{
handleRestoreHiddenRecords
}
disabled=
{
saving
||
hiddenRecordsLoading
||
selectedHiddenRecordIds
.
length
===
0
}
>
恢复勾选记录
</
Button
>
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
isGroupDialogOpen
}
onClose=
{
setIsGroupDialogOpen
}
size=
"2xl"
>
<
DialogTitle
>
{
groupForm
.
recordName
?
'编辑视图分组'
:
'新建视图分组'
}
</
DialogTitle
>
<
DialogDescription
>
分组会以树形菜单展示在左侧,用于承载当前业务主题下的多个分析视图。
</
DialogDescription
>
<
DialogBody
>
<
div
className=
"grid gap-4"
>
<
div
>
<
FieldLabel
required
>
分组名称
</
FieldLabel
>
<
Input
value=
{
groupForm
.
groupName
}
onChange=
{
(
event
)
=>
setGroupForm
((
prev
)
=>
({
...
prev
,
groupName
:
event
.
target
.
value
,
}))
}
/>
</
div
>
<
div
>
<
FieldLabel
>
父分组
</
FieldLabel
>
<
Select
value=
{
groupForm
.
parentGroupId
}
onChange=
{
(
event
)
=>
setGroupForm
((
prev
)
=>
({
...
prev
,
parentGroupId
:
event
.
target
.
value
,
}))
}
>
<
option
value=
""
>
作为一级分组
</
option
>
{
flatGroupOptions
.
filter
((
group
)
=>
group
.
id
!==
groups
.
find
((
item
)
=>
item
.
recordName
===
groupForm
.
recordName
)?.
id
)
.
map
((
group
)
=>
(
<
option
key=
{
group
.
id
}
value=
{
group
.
id
}
>
{
group
.
label
}
</
option
>
))
}
</
Select
>
</
div
>
</
div
>
</
DialogBody
>
<
DialogActions
>
<
Button
outline
onClick=
{
()
=>
setIsGroupDialogOpen
(
false
)
}
>
取消
</
Button
>
<
Button
color=
"blue"
onClick=
{
handleSaveGroup
}
disabled=
{
saving
}
>
保存分组
</
Button
>
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
isViewDialogOpen
}
onClose=
{
setIsViewDialogOpen
}
size=
"5xl"
open=
{
isDeleteGroupDialogOpen
}
onClose=
{
(
nextOpen
)
=>
{
setIsDeleteGroupDialogOpen
(
nextOpen
);
if
(
!
nextOpen
)
{
setGroupPendingDelete
(
null
);
}
}
}
size=
"lg"
>
<
DialogTitle
>
确认删除分组
</
DialogTitle
>
<
DialogDescription
>
删除后当前分组会立即移除,且无法撤回。请确认该分组下已经没有子分组和视图。
</
DialogDescription
>
<
DialogBody
>
<
div
className=
"rounded-[22px] border border-red-200 bg-red-50 px-4 py-4 text-sm leading-6 text-red-700"
>
你将删除分组“
{
groupPendingDelete
?.
groupName
||
'未命名分组'
}
”。
</
div
>
</
DialogBody
>
<
DialogActions
>
<
Button
outline
onClick=
{
()
=>
{
setIsDeleteGroupDialogOpen
(
false
);
setGroupPendingDelete
(
null
);
}
}
>
取消
</
Button
>
<
Button
color=
"red"
onClick=
{
handleDeleteGroup
}
disabled=
{
saving
}
>
确认删除
</
Button
>
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
isViewDialogOpen
}
onClose=
{
setIsViewDialogOpen
}
size=
"5xl"
>
<
DialogTitle
>
{
viewForm
.
recordName
?
'编辑分析视图'
:
'新建分析视图'
}
</
DialogTitle
>
<
DialogDescription
>
视图会保存对象、筛选字段、默认筛选、结果列
和图表配置
,后续业务同事直接打开即可复用。
视图会保存对象、筛选字段、默认筛选、结果列
、图表配置、所属分组和标签
,后续业务同事直接打开即可复用。
</
DialogDescription
>
<
DialogBody
>
<
div
className=
"grid gap-5"
>
...
...
@@ -883,9 +1977,7 @@ export default function AnalysisViews() {
<
SubjectSearchCombobox
subjects=
{
subjects
}
value=
{
subjects
.
find
(
(
subject
)
=>
subject
.
name
===
viewForm
.
subjectName
,
)
||
subjects
.
find
((
subject
)
=>
subject
.
name
===
viewForm
.
subjectName
)
||
(
viewForm
.
subjectName
?
{
name
:
viewForm
.
subjectName
,
...
...
@@ -897,6 +1989,45 @@ export default function AnalysisViews() {
onQueryChange=
{
setSubjectKeyword
}
placeholder=
"搜索并选择绑定对象"
/>
{
subjectSearching
?
(
<
div
className=
"mt-2 text-xs text-zinc-400"
>
正在搜索对象...
</
div
>
)
:
null
}
</
div
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
>
<
FieldLabel
>
所属分组
</
FieldLabel
>
<
Select
value=
{
viewForm
.
groupId
}
onChange=
{
(
event
)
=>
setViewForm
((
prev
)
=>
({
...
prev
,
groupId
:
event
.
target
.
value
,
groupName
:
groups
.
find
((
group
)
=>
group
.
id
===
event
.
target
.
value
)?.
groupName
||
''
,
}))
}
>
<
option
value=
""
>
暂不归档到分组
</
option
>
{
flatGroupOptions
.
map
((
group
)
=>
(
<
option
key=
{
group
.
id
}
value=
{
group
.
id
}
>
{
group
.
label
}
</
option
>
))
}
</
Select
>
</
div
>
<
div
>
<
FieldLabel
>
视图标签
</
FieldLabel
>
<
TagEditor
values=
{
viewForm
.
tags
||
[]
}
onChange=
{
(
nextTags
)
=>
setViewForm
((
prev
)
=>
({
...
prev
,
tags
:
nextTags
,
}))
}
/>
</
div
>
</
div
>
...
...
@@ -998,9 +2129,7 @@ export default function AnalysisViews() {
charts
:
[
...(
prev
.
charts
||
[]),
buildChartDraft
(
viewSubjectDefinition
?.
fields
||
subjectDefinition
?.
fields
||
[],
viewSubjectDefinition
?.
fields
||
subjectDefinition
?.
fields
||
[],
),
],
}))
...
...
@@ -1191,9 +2320,7 @@ export default function AnalysisViews() {
</
div
>
<
div
className=
"rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4"
>
<
div
className=
"text-sm font-semibold text-zinc-950"
>
默认筛选值
</
div
>
<
div
className=
"text-sm font-semibold text-zinc-950"
>
默认筛选值
</
div
>
<
div
className=
"mt-3 grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
{
(
viewSubjectDefinition
?.
fields
||
subjectDefinition
?.
fields
||
[])
.
filter
((
field
)
=>
viewForm
.
filterFields
.
includes
(
field
.
alias
))
...
...
@@ -1228,19 +2355,15 @@ export default function AnalysisViews() {
</
DialogActions
>
</
Dialog
>
<
Dialog
open=
{
isForwardDialogOpen
}
onClose=
{
setIsForwardDialogOpen
}
size=
"3xl"
>
<
Dialog
open=
{
isForwardDialogOpen
}
onClose=
{
setIsForwardDialogOpen
}
size=
"4xl"
>
<
DialogTitle
>
提交转发审批
</
DialogTitle
>
<
DialogDescription
>
当前
会把勾选记录绑定到来源视图,并同步记录一级审批状态与最终审批状态
。
当前
批次会记录“本次下发”和“本次不下发”的记录,未结束前不下发记录会从原表格中临时隐藏
。
</
DialogDescription
>
<
DialogBody
>
<
div
className=
"grid gap-4"
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600"
>
已勾选
{
selectedRecords
.
length
}
条记录,来源视图为
当前
已勾选
{
selectedRecords
.
length
}
条记录,来源视图为
<
span
className=
"mx-1 font-medium text-zinc-950"
>
{
activeView
?.
title
||
'-'
}
</
span
>
...
...
@@ -1251,6 +2374,47 @@ export default function AnalysisViews() {
。
</
div
>
<
div
className=
"rounded-[24px] border border-zinc-200 bg-white p-4"
>
<
SectionHeader
title=
"本次记录去向"
description=
"默认全部作为“本次下发”处理,取消勾选后会进入“不下发”列表,并在当前批次未结束前从表格中隐藏。"
/>
<
div
className=
"grid gap-3"
>
{
selectedRecords
.
map
((
record
)
=>
{
const
recordKey
=
record
.
recordName
||
record
.
id
;
const
excluded
=
(
forwardForm
.
excludedRecordIds
||
[]).
includes
(
recordKey
);
return
(
<
CheckboxField
key=
{
record
.
id
}
className=
"rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<
Checkbox
color=
"sky"
checked=
{
!
excluded
}
onChange=
{
(
checked
)
=>
setForwardForm
((
prev
)
=>
({
...
prev
,
excludedRecordIds
:
checked
?
(
prev
.
excludedRecordIds
||
[]).
filter
((
id
)
=>
id
!==
recordKey
)
:
[...
new
Set
([...(
prev
.
excludedRecordIds
||
[]),
recordKey
])],
}))
}
/>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-sm font-medium text-zinc-800"
>
{
record
.
title
||
record
.
recordName
||
record
.
id
}
</
div
>
<
div
className=
"mt-1 text-xs text-zinc-500"
>
当前将归入
{
excluded
?
'“本次不下发”'
:
'“本次下发”'
}
,
状态为
{
getForwardRowStatusLabel
(
rowStatusByRecordId
[
recordKey
]
||
'pending_send'
)
}
</
div
>
</
div
>
</
CheckboxField
>
);
})
}
</
div
>
</
div
>
<
UserMultiSelectField
label=
"转发接收人"
required
...
...
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
0 → 100644
View file @
5a3fb30d
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/ForwardApprovals.jsx
View file @
5a3fb30d
...
...
@@ -6,24 +6,29 @@ import {
DialogDescription
,
DialogTitle
,
}
from
'@/catalyst-ui-kit/javascript/dialog'
;
import
{
Textarea
}
from
'@/catalyst-ui-kit/javascript/textarea'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
ApprovalStatusTag
,
DynamicFieldInput
,
EmptyCollection
,
FieldLabel
,
HeroBanner
,
NoticeBanner
,
Panel
,
SectionHeader
,
Tag
,
UserSelectField
,
}
from
'./AnalysisShared'
;
import
{
createEmptyForwardDraft
,
findIncompleteForwardItemRecords
,
FORWARD_ITEM_FILL_FIELDS
,
formatFieldValue
,
formatDateTime
,
getApprovalStatusLabel
,
getEditorStatusLabel
,
getFieldInputKind
,
loadForwardEditableRecords
,
loadForwardRecords
,
saveForward
Record
,
saveForward
EditableRecords
,
updateForwardRecordStatus
,
}
from
'./analysisService'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
...
...
@@ -41,11 +46,16 @@ export default function ForwardApprovals() {
const
[
activeFilter
,
setActiveFilter
]
=
useState
(
'initiated'
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
editLoading
,
setEditLoading
]
=
useState
(
false
);
const
[
editSaving
,
setEditSaving
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
notice
,
setNotice
]
=
useState
(
''
);
const
[
activeRecordId
,
setActiveRecordId
]
=
useState
(
''
);
const
[
isDialogOpen
,
setIsDialogOpen
]
=
useState
(
false
);
const
[
manualForwardDraft
,
setManualForwardDraft
]
=
useState
(
createEmptyForwardDraft
);
const
[
isEditDialogOpen
,
setIsEditDialogOpen
]
=
useState
(
false
);
const
[
editingRecord
,
setEditingRecord
]
=
useState
(
null
);
const
[
editableSubjectDefinition
,
setEditableSubjectDefinition
]
=
useState
(
null
);
const
[
editableRecords
,
setEditableRecords
]
=
useState
([]);
const
[
activeEditableRecordId
,
setActiveEditableRecordId
]
=
useState
(
''
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -56,6 +66,14 @@ export default function ForwardApprovals() {
const
result
=
await
loadForwardRecords
();
if
(
!
cancelled
)
{
setRecords
(
result
);
const
hasEditableInvolvedRecord
=
result
.
some
(
(
record
)
=>
record
.
recipientIds
.
includes
(
currentUserId
)
&&
record
.
editorStatus
!==
'completed'
,
);
if
(
hasEditableInvolvedRecord
)
{
setActiveFilter
(
'involved'
);
}
setError
(
''
);
}
}
catch
(
loadError
)
{
...
...
@@ -72,7 +90,17 @@ export default function ForwardApprovals() {
return
()
=>
{
cancelled
=
true
;
};
},
[]);
},
[
currentUserId
]);
const
editableInvolvedCount
=
useMemo
(
()
=>
records
.
filter
(
(
record
)
=>
record
.
recipientIds
.
includes
(
currentUserId
)
&&
record
.
editorStatus
!==
'completed'
,
).
length
,
[
currentUserId
,
records
],
);
const
filteredRecords
=
useMemo
(()
=>
{
return
records
.
filter
((
record
)
=>
{
...
...
@@ -83,8 +111,10 @@ export default function ForwardApprovals() {
if
(
activeFilter
===
'pending'
)
{
return
(
(
record
.
firstApprover
===
currentUserId
&&
record
.
editorStatus
===
'completed'
&&
record
.
approvalStatus
===
'pending'
)
||
(
record
.
finalApprover
===
currentUserId
&&
record
.
editorStatus
===
'completed'
&&
record
.
approvalStatus
===
'approved'
&&
record
.
finalApprovalStatus
===
'pending'
)
);
...
...
@@ -125,8 +155,130 @@ export default function ForwardApprovals() {
}
}
async
function
openEditDialog
(
record
)
{
if
(
!
record
||
!
(
record
.
recipientIds
.
includes
(
currentUserId
)
||
record
.
firstApprover
===
currentUserId
||
record
.
finalApprover
===
currentUserId
)
)
{
return
;
}
setEditingRecord
(
record
);
setIsEditDialogOpen
(
true
);
setEditLoading
(
true
);
setError
(
''
);
try
{
const
result
=
await
loadForwardEditableRecords
(
record
);
setEditableSubjectDefinition
(
result
.
subjectDefinition
);
setEditableRecords
(
result
.
records
);
setActiveEditableRecordId
(
result
.
records
[
0
]?.
id
||
''
);
}
catch
(
loadError
)
{
setError
(
loadError
?.
message
||
'填写数据加载失败'
);
setEditingRecord
(
null
);
setEditableSubjectDefinition
(
null
);
setEditableRecords
([]);
setActiveEditableRecordId
(
''
);
}
finally
{
setEditLoading
(
false
);
}
}
function
handleChangeEditableRecord
(
recordId
,
fieldAlias
,
nextValue
)
{
const
aliasMap
=
{
truth_status
:
'truthStatus'
,
rectification_plan
:
'rectificationPlan'
,
expected_rectification_completed_at
:
'expectedRectificationCompletedAt'
,
};
const
displayAlias
=
aliasMap
[
fieldAlias
];
setEditableRecords
((
prev
)
=>
prev
.
map
((
record
)
=>
record
.
id
===
recordId
?
{
...
record
,
[
fieldAlias
]:
nextValue
,
...(
displayAlias
?
{
[
displayAlias
]:
nextValue
}
:
{}),
}
:
record
,
),
);
}
async
function
handleSaveEditableRecords
()
{
if
(
!
editingRecord
)
{
return
;
}
setEditSaving
(
true
);
try
{
const
saved
=
await
saveForwardEditableRecords
(
editingRecord
,
editableRecords
,
userInfo
,
);
setEditableRecords
(
saved
);
setNotice
(
'填写数据已保存。'
);
setError
(
''
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'填写数据保存失败'
);
}
finally
{
setEditSaving
(
false
);
}
}
async
function
handleCompleteEditing
()
{
if
(
!
editingRecord
)
{
return
;
}
const
incompleteRecords
=
findIncompleteForwardItemRecords
(
editableRecords
);
if
(
incompleteRecords
.
length
>
0
)
{
setError
(
`还有
${
incompleteRecords
.
length
}
条记录未完整填写是否属实、整改方案或预计整改完成时间。`
);
return
;
}
setEditSaving
(
true
);
try
{
if
(
editableRecords
.
length
>
0
)
{
const
saved
=
await
saveForwardEditableRecords
(
editingRecord
,
editableRecords
,
userInfo
,
);
setEditableRecords
(
saved
);
}
const
updatedForwardRecord
=
await
updateForwardRecordStatus
(
editingRecord
.
recordName
,
{
editor_status
:
'completed'
,
completed_by_id
:
userInfo
?.
_id
||
userInfo
?.
id
||
userInfo
?.
username
||
''
,
completed_by_name
:
userInfo
?.
display_name
||
userInfo
?.
displayName
||
userInfo
?.
username
||
''
,
completed_at
:
new
Date
().
toISOString
(),
});
setEditingRecord
(
updatedForwardRecord
);
setIsEditDialogOpen
(
false
);
await
refreshRecords
(
'填写已完成,当前批次已进入待审批。'
);
setError
(
''
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'完成填写失败'
);
}
finally
{
setEditSaving
(
false
);
}
}
async
function
handleApprove
(
record
)
{
if
(
record
.
firstApprover
===
currentUserId
&&
record
.
approvalStatus
===
'pending'
)
{
if
(
record
.
firstApprover
===
currentUserId
&&
record
.
editorStatus
===
'completed'
&&
record
.
approvalStatus
===
'pending'
)
{
await
handleUpdateRecord
(
record
,
{
approval_status
:
'approved'
});
return
;
}
...
...
@@ -141,7 +293,11 @@ export default function ForwardApprovals() {
}
async
function
handleReject
(
record
)
{
if
(
record
.
firstApprover
===
currentUserId
&&
record
.
approvalStatus
===
'pending'
)
{
if
(
record
.
firstApprover
===
currentUserId
&&
record
.
editorStatus
===
'completed'
&&
record
.
approvalStatus
===
'pending'
)
{
await
handleUpdateRecord
(
record
,
{
approval_status
:
'rejected'
,
final_approval_status
:
'pending'
,
...
...
@@ -158,46 +314,72 @@ export default function ForwardApprovals() {
}
}
async
function
handleCreateManualForward
()
{
if
(
!
String
(
manualForwardDraft
.
note
||
''
).
trim
())
{
setError
(
'请先填写转发说明。'
);
return
;
}
const
canCurrentUserEdit
=
Boolean
(
activeRecord
&&
activeRecord
.
recipientIds
.
includes
(
currentUserId
)
&&
activeRecord
.
editorStatus
!==
'completed'
,
);
if
(
!
manualForwardDraft
.
firstApprover
||
!
manualForwardDraft
.
finalApprover
)
{
setError
(
'请指定一级审批人和终审人。'
);
return
;
}
const
canCurrentUserViewData
=
Boolean
(
activeRecord
&&
(
activeRecord
.
recipientIds
.
includes
(
currentUserId
)
||
activeRecord
.
firstApprover
===
currentUserId
||
activeRecord
.
finalApprover
===
currentUserId
),
);
setSaving
(
true
);
try
{
await
saveForwardRecord
(
{
viewId
:
'manual_forward_view'
,
viewName
:
'手工补录转发'
,
subjectName
:
'manual_forward'
,
selectedRecordIds
:
[],
recipientIds
:
[],
recipientNames
:
[],
note
:
manualForwardDraft
.
note
,
firstApprover
:
manualForwardDraft
.
firstApprover
.
id
,
firstApproverName
:
manualForwardDraft
.
firstApprover
.
label
,
finalApprover
:
manualForwardDraft
.
finalApprover
.
id
,
finalApproverName
:
manualForwardDraft
.
finalApprover
.
label
,
approvalStatus
:
'pending'
,
finalApprovalStatus
:
'pending'
,
},
userInfo
,
const
canEditingRecordEdit
=
Boolean
(
editingRecord
&&
editingRecord
.
recipientIds
.
includes
(
currentUserId
)
&&
editingRecord
.
editorStatus
!==
'completed'
,
);
const
activeEditableRecord
=
useMemo
(
()
=>
editableRecords
.
find
((
record
)
=>
record
.
id
===
activeEditableRecordId
)
||
editableRecords
[
0
]
||
null
,
[
activeEditableRecordId
,
editableRecords
],
);
const
simpleSummaryFields
=
useMemo
(()
=>
{
const
fields
=
editableSubjectDefinition
?.
fields
||
[];
return
fields
.
filter
((
field
)
=>
{
const
kind
=
getFieldInputKind
(
field
);
return
[
'text'
,
'number'
,
'date'
,
'boolean'
,
'select'
,
'reference'
].
includes
(
kind
);
})
.
slice
(
0
,
3
);
},
[
editableSubjectDefinition
?.
fields
]);
const
dirtyRecordIdSet
=
useMemo
(()
=>
{
return
new
Set
(
editableRecords
.
filter
(
(
record
)
=>
(
record
.
truth_status
||
''
)
!==
(
record
.
forwardItem
?.
truthStatus
||
''
)
||
(
record
.
rectification_plan
||
''
)
!==
(
record
.
forwardItem
?.
rectificationPlan
||
''
)
||
(
record
.
expected_rectification_completed_at
||
''
)
!==
(
record
.
forwardItem
?.
expectedRectificationCompletedAt
||
''
),
)
.
map
((
record
)
=>
record
.
id
),
);
},
[
editableRecords
]);
function
renderReadonlyField
(
field
,
value
)
{
const
kind
=
getFieldInputKind
(
field
);
const
singleColumn
=
[
'textarea'
,
'json'
,
'subject'
,
'file'
].
includes
(
kind
);
return
(
<
div
key=
{
field
.
alias
}
className=
{
singleColumn
?
'md:col-span-2'
:
''
}
>
<
FieldLabel
required=
{
field
.
isRequired
}
>
{
field
.
label
}
</
FieldLabel
>
<
div
className=
"rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-6 text-zinc-700"
>
{
formatFieldValue
(
field
,
value
)
}
</
div
>
</
div
>
);
setIsDialogOpen
(
false
);
setManualForwardDraft
(
createEmptyForwardDraft
());
await
refreshRecords
(
'已补录一条手工转发审批记录。'
);
setError
(
''
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'手工补录失败'
);
}
finally
{
setSaving
(
false
);
}
}
return
(
...
...
@@ -206,18 +388,6 @@ export default function ForwardApprovals() {
eyebrow=
"FORWARD APPROVALS"
title=
"转发审批页负责把视图分析结果继续推到协作链路里"
description=
"这里集中展示我的发起、待我审批和我参与的转发记录。两级顺序审批会明确区分“审批状态”和“最终审批状态”,终审只能在一级审批通过后再推进。"
actions=
{
<
Button
color=
"blue"
onClick=
{
()
=>
{
setIsDialogOpen
(
true
);
setNotice
(
''
);
setError
(
''
);
}
}
>
手工补录转发
</
Button
>
}
/>
{
loading
?
<
NoticeBanner
>
正在加载转发审批记录...
</
NoticeBanner
>
:
null
}
...
...
@@ -245,6 +415,11 @@ export default function ForwardApprovals() {
</
button
>
))
}
</
div
>
{
editableInvolvedCount
>
0
?
(
<
div
className=
"mt-4 rounded-[20px] border border-sky-200 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-700"
>
你当前有
{
editableInvolvedCount
}
条待填写记录。编辑入口在“我参与的”视角,选中批次后点击右侧“进入填写”即可补充行级回填。
</
div
>
)
:
null
}
</
Panel
>
<
div
className=
"grid gap-6 xl:grid-cols-[0.92fr_1.08fr]"
>
...
...
@@ -279,6 +454,9 @@ export default function ForwardApprovals() {
</
div
>
</
div
>
<
div
className=
"flex flex-wrap gap-2"
>
<
Tag
tone=
{
record
.
editorStatus
===
'completed'
?
'emerald'
:
'amber'
}
>
{
getEditorStatusLabel
(
record
.
editorStatus
)
}
</
Tag
>
<
ApprovalStatusTag
status=
{
record
.
approvalStatus
}
/>
<
ApprovalStatusTag
status=
{
record
.
finalApprovalStatus
}
/>
</
div
>
...
...
@@ -288,7 +466,7 @@ export default function ForwardApprovals() {
)
:
(
<
EmptyCollection
title=
"当前视角下没有审批记录"
description=
"可以
从分析视图页勾选记录发起转发,也可以在本页手工补录一条审批记录
。"
description=
"可以
回到分析视图页勾选记录并发起新的转发审批
。"
/>
)
}
</
div
>
...
...
@@ -312,6 +490,9 @@ export default function ForwardApprovals() {
</
div
>
</
div
>
<
div
className=
"flex flex-wrap gap-2"
>
<
Tag
tone=
{
activeRecord
.
editorStatus
===
'completed'
?
'emerald'
:
'amber'
}
>
{
getEditorStatusLabel
(
activeRecord
.
editorStatus
)
}
</
Tag
>
<
ApprovalStatusTag
status=
{
activeRecord
.
approvalStatus
}
/>
<
ApprovalStatusTag
status=
{
activeRecord
.
finalApprovalStatus
}
/>
</
div
>
...
...
@@ -340,6 +521,17 @@ export default function ForwardApprovals() {
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-white p-4"
>
<
div
className=
"text-sm font-medium text-zinc-500"
>
填写状态
</
div
>
<
div
className=
"mt-2 text-base font-semibold text-zinc-950"
>
{
getEditorStatusLabel
(
activeRecord
.
editorStatus
)
}
</
div
>
<
div
className=
"mt-2 text-sm text-zinc-500"
>
{
activeRecord
.
completedByName
?
`已由 ${activeRecord.completedByName} 于 ${formatDateTime(activeRecord.completedAt)} 完成填写`
:
'接收人仍可进入填写并保存修改'
}
</
div
>
</
div
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-white p-4"
>
<
div
className=
"text-sm font-medium text-zinc-500"
>
一级审批人
</
div
>
<
div
className=
"mt-2 text-base font-semibold text-zinc-950"
>
...
...
@@ -360,6 +552,22 @@ export default function ForwardApprovals() {
</
div
>
</
div
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-white p-4"
>
<
div
className=
"text-sm font-medium text-zinc-500"
>
数据查看与填写
</
div
>
<
div
className=
"mt-3 flex flex-col gap-3 sm:flex-row"
>
<
Button
color=
"blue"
disabled=
{
saving
||
!
canCurrentUserViewData
}
onClick=
{
()
=>
openEditDialog
(
activeRecord
)
}
>
{
canCurrentUserEdit
?
'进入填写'
:
'查看本批次数据'
}
</
Button
>
</
div
>
<
div
className=
"mt-3 text-xs leading-5 text-zinc-500"
>
规则:填写人、一级审批人和终审人都可以查看这批次数据;只有接收人且批次仍处于填写中时可以修改。任意一位接收人点击“完成填写”后,这批数据即锁定为不可修改,并进入待审批状态。
</
div
>
</
div
>
<
div
className=
"rounded-[22px] border border-zinc-200 bg-zinc-50 p-4"
>
<
div
className=
"text-sm font-medium text-zinc-500"
>
审批动作
</
div
>
<
div
className=
"mt-3 flex flex-col gap-3 sm:flex-row"
>
...
...
@@ -369,8 +577,10 @@ export default function ForwardApprovals() {
saving
||
!
(
(
activeRecord
.
firstApprover
===
currentUserId
&&
activeRecord
.
editorStatus
===
'completed'
&&
activeRecord
.
approvalStatus
===
'pending'
)
||
(
activeRecord
.
finalApprover
===
currentUserId
&&
activeRecord
.
editorStatus
===
'completed'
&&
activeRecord
.
approvalStatus
===
'approved'
&&
activeRecord
.
finalApprovalStatus
===
'pending'
)
)
...
...
@@ -385,8 +595,10 @@ export default function ForwardApprovals() {
saving
||
!
(
(
activeRecord
.
firstApprover
===
currentUserId
&&
activeRecord
.
editorStatus
===
'completed'
&&
activeRecord
.
approvalStatus
===
'pending'
)
||
(
activeRecord
.
finalApprover
===
currentUserId
&&
activeRecord
.
editorStatus
===
'completed'
&&
activeRecord
.
approvalStatus
===
'approved'
&&
activeRecord
.
finalApprovalStatus
===
'pending'
)
)
...
...
@@ -425,62 +637,204 @@ export default function ForwardApprovals() {
</
Panel
>
</
div
>
<
Dialog
open=
{
isDialogOpen
}
onClose=
{
setIsDialogOpen
}
size=
"3xl"
>
<
DialogTitle
>
手工补录转发审批
</
DialogTitle
>
<
Dialog
open=
{
isEditDialogOpen
}
onClose=
{
(
nextOpen
)
=>
{
setIsEditDialogOpen
(
nextOpen
);
if
(
!
nextOpen
)
{
setEditingRecord
(
null
);
}
}
}
size=
"5xl"
>
<
DialogTitle
>
填写下发数据
</
DialogTitle
>
<
DialogDescription
>
当外部线下动作已经发生,但仍需要在系统里补齐审批轨迹时,可以在这里补录。
{
canEditingRecordEdit
?
'接收人需要逐条填写是否属实、整改方案和预计整改完成时间。点击“完成填写”后,本批次回填数据会锁定并进入待审批状态。'
:
'当前角色可查看本批次下发记录及行级回填数据;如该批次已完成填写或当前角色不是接收人,则仅可查看不可修改。'
}
</
DialogDescription
>
<
DialogBody
>
<
div
className=
"grid gap-4"
>
{
editLoading
?
(
<
NoticeBanner
>
正在加载可填写数据...
</
NoticeBanner
>
)
:
editableRecords
.
length
>
0
&&
editableSubjectDefinition
?
(
<
div
className=
"grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)]"
>
<
div
className=
"rounded-[24px] border border-zinc-200 bg-zinc-50/70 p-4"
>
<
div
className=
"mb-4 flex items-start justify-between gap-3"
>
<
div
>
<
div
className=
"mb-2 text-sm font-medium text-zinc-600"
>
补录说明
</
div
>
<
Textarea
rows=
{
4
}
value=
{
manualForwardDraft
.
note
}
onChange=
{
(
event
)
=>
setManualForwardDraft
((
prev
)
=>
({
...
prev
,
note
:
event
.
target
.
value
,
}))
}
/>
<
div
className=
"text-base font-semibold text-zinc-950"
>
本批次共
{
editableRecords
.
length
}
条记录
</
div
>
<
div
className=
"mt-1 text-sm text-zinc-500"
>
{
editingRecord
?.
title
||
editingRecord
?.
viewName
||
'当前批次'
}
·
{
editingRecord
?.
subjectName
||
'-'
}
</
div
>
</
div
>
<
Tag
tone=
{
canEditingRecordEdit
?
'amber'
:
'sky'
}
>
{
canEditingRecordEdit
?
'可编辑'
:
'只读查看'
}
</
Tag
>
</
div
>
<
div
className=
"max-h-[32rem] space-y-3 overflow-y-auto pr-1"
>
{
editableRecords
.
map
((
record
,
index
)
=>
{
const
active
=
activeEditableRecord
?.
id
===
record
.
id
;
const
dirty
=
dirtyRecordIdSet
.
has
(
record
.
id
);
return
(
<
button
key=
{
record
.
id
}
type=
"button"
onClick=
{
()
=>
setActiveEditableRecordId
(
record
.
id
)
}
className=
{
`w-full rounded-[20px] border px-4 py-4 text-left transition ${
active
? 'border-sky-300 bg-white shadow-sm'
: 'border-zinc-200 bg-white/70 hover:border-zinc-300'
}`
}
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
className=
"min-w-0"
>
<
div
className=
"text-xs font-medium uppercase tracking-[0.16em] text-zinc-400"
>
记录
{
index
+
1
}
</
div
>
<
div
className=
"mt-2 truncate text-sm font-semibold text-zinc-950"
>
{
record
.
title
||
record
.
recordName
||
record
.
id
}
</
div
>
</
div
>
<
div
className=
"flex flex-col items-end gap-2"
>
{
active
?
<
Tag
tone=
"sky"
>
当前编辑中
</
Tag
>
:
null
}
{
dirty
?
<
Tag
tone=
"amber"
>
已修改
</
Tag
>
:
null
}
{
!
canEditingRecordEdit
?
<
Tag
>
只读
</
Tag
>
:
null
}
</
div
>
</
div
>
<
div
className=
"mt-3 space-y-1"
>
{
simpleSummaryFields
.
map
((
field
)
=>
(
<
div
key=
{
field
.
alias
}
className=
"flex items-start justify-between gap-3 text-xs"
>
<
span
className=
"shrink-0 text-zinc-400"
>
{
field
.
label
}
</
span
>
<
span
className=
"truncate text-right text-zinc-600"
>
{
formatFieldValue
(
field
,
record
[
field
.
alias
])
}
</
span
>
</
div
>
))
}
</
div
>
</
button
>
);
})
}
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-zinc-200 bg-white p-5"
>
{
activeEditableRecord
?
(
<
div
className=
"space-y-5"
>
<
div
className=
"flex flex-col gap-3 border-b border-zinc-200 pb-4 sm:flex-row sm:items-start sm:justify-between"
>
<
div
>
<
div
className=
"text-lg font-semibold text-zinc-950"
>
{
activeEditableRecord
.
title
||
activeEditableRecord
.
recordName
||
activeEditableRecord
.
id
}
</
div
>
<
div
className=
"mt-1 text-sm text-zinc-500"
>
当前第
{
editableRecords
.
findIndex
((
record
)
=>
record
.
id
===
activeEditableRecord
.
id
)
+
1
}
条,共
{
editableRecords
.
length
}
条
</
div
>
</
div
>
<
div
className=
"flex flex-wrap gap-2"
>
<
Tag
>
{
editingRecord
?.
subjectName
||
'-'
}
</
Tag
>
{
dirtyRecordIdSet
.
has
(
activeEditableRecord
.
id
)
?
(
<
Tag
tone=
"amber"
>
当前记录未保存
</
Tag
>
)
:
(
<
Tag
tone=
"emerald"
>
当前记录已同步
</
Tag
>
)
}
</
div
>
</
div
>
{
simpleSummaryFields
.
length
>
0
?
(
<
div
className=
"grid gap-3 md:grid-cols-2 xl:grid-cols-3"
>
{
simpleSummaryFields
.
map
((
field
)
=>
(
<
div
key=
{
field
.
alias
}
className=
"rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<
div
className=
"text-xs font-medium uppercase tracking-[0.14em] text-zinc-400"
>
{
field
.
label
}
</
div
>
<
div
className=
"mt-2 truncate text-sm font-medium text-zinc-800"
>
{
formatFieldValue
(
field
,
activeEditableRecord
[
field
.
alias
])
}
</
div
>
</
div
>
))
}
</
div
>
)
:
null
}
<
div
className=
"max-h-[32rem] overflow-y-auto pr-1"
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
UserSelectField
label=
"一级审批人"
required
value=
{
manualForwardDraft
.
firstApprover
}
onChange=
{
(
nextValue
)
=>
setManualForwardDraft
((
prev
)
=>
({
...
prev
,
firstApprover
:
nextValue
,
}))
}
/>
<
UserSelectField
label=
"终审人"
required
value=
{
manualForwardDraft
.
finalApprover
}
{
FORWARD_ITEM_FILL_FIELDS
.
map
((
field
)
=>
{
const
singleColumn
=
[
'textarea'
,
'json'
,
'subject'
,
'file'
].
includes
(
getFieldInputKind
(
field
),
);
return
(
<
div
key=
{
`${activeEditableRecord.id}_${field.alias}`
}
className=
{
singleColumn
?
'md:col-span-2'
:
''
}
>
{
canEditingRecordEdit
?
(
<
DynamicFieldInput
field=
{
field
}
value=
{
activeEditableRecord
[
field
.
alias
]
}
disabled=
{
!
canEditingRecordEdit
}
onChange=
{
(
nextValue
)
=>
setManualForwardDraft
((
prev
)
=>
({
...
prev
,
finalApprover
:
nextValue
,
}))
handleChangeEditableRecord
(
activeEditableRecord
.
id
,
field
.
alias
,
nextValue
,
)
}
/>
)
:
(
renderReadonlyField
(
field
,
activeEditableRecord
[
field
.
alias
])
)
}
</
div
>
);
})
}
</
div
>
</
div
>
</
div
>
)
:
null
}
</
div
>
</
div
>
)
:
(
<
EmptyCollection
title=
"当前批次没有可填写记录"
description=
"请先确认本批次是否带有下发记录,或这些记录是否已经存在于 Record 数据中。"
/>
)
}
</
DialogBody
>
<
DialogActions
>
<
Button
outline
onClick=
{
()
=>
setIsDialogOpen
(
false
)
}
>
取消
<
Button
outline
onClick=
{
()
=>
{
setIsEditDialogOpen
(
false
);
setEditingRecord
(
null
);
}
}
>
关闭
</
Button
>
<
Button
color=
"blue"
onClick=
{
handleCreateManualForward
}
disabled=
{
saving
}
>
确认补录
<
Button
outline
disabled=
{
editLoading
||
editSaving
||
editableRecords
.
length
===
0
||
!
canEditingRecordEdit
}
onClick=
{
handleSaveEditableRecords
}
>
保存修改
</
Button
>
<
Button
color=
"blue"
disabled=
{
editLoading
||
editSaving
||
editableRecords
.
length
===
0
||
!
canEditingRecordEdit
}
onClick=
{
handleCompleteEditing
}
>
完成填写
</
Button
>
</
DialogActions
>
</
Dialog
>
</
div
>
);
}
src/pages/project_manager_xy/Layout.jsx
View file @
5a3fb30d
import
ApplicationSwitcherDialog
from
'@/pages/project_manager_xy/ApplicationSwitcherDialog'
;
import
xyLogo
from
'@/assets/xy-logo.png'
;
import
xyLogoSmall
from
'@/assets/xy-logo_small.png'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
ArrowsRightLeftIcon
,
Bars3Icon
,
ChartBarSquareIcon
,
ChartPieIcon
,
ClipboardDocumentListIcon
,
Squares2X2Icon
,
XMarkIcon
,
}
from
'@heroicons/react/24/outline'
;
import
clsx
from
'clsx'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
use
Context
,
use
Effect
,
useMemo
,
useState
}
from
'react'
;
import
{
Link
,
Outlet
,
useLocation
}
from
'umi'
;
const
APP_NAME
=
'数据分析工具'
;
...
...
@@ -185,7 +189,10 @@ 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
);
...
...
@@ -229,6 +236,23 @@ 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-xl border border-zinc-700 bg-zinc-800 text-zinc-300 shadow-sm transition duration-200 hover:border-[#fb923c]/70 hover:bg-zinc-800/90 hover:text-[#fdba74] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/40'
,
sidebarCollapsed
?
'size-9'
:
'size-10'
,
)
}
>
<
Squares2X2Icon
className=
{
clsx
(
sidebarCollapsed
?
'size-[18px]'
:
'size-5'
)
}
/>
</
button
>
);
return
(
<
div
className=
"flex h-screen overflow-hidden bg-zinc-50"
>
<
aside
...
...
@@ -239,22 +263,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"
>
...
...
@@ -280,6 +316,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"
>
...
...
@@ -307,6 +354,7 @@ export default function ProjectManagerLayout() {
close=
{
()
=>
setIsMobileSidebarOpen
(
false
)
}
>
<
div
className=
"px-5 pt-6"
>
<
div
>
<
div
className=
"flex items-center gap-2"
>
<
img
src=
{
xyLogo
}
...
...
@@ -317,6 +365,10 @@ export default function ProjectManagerLayout() {
{
APP_NAME
}
</
h1
>
</
div
>
<
div
className=
"mt-1 text-xs text-zinc-500"
>
配置驱动分析工作区
</
div
>
</
div
>
</
div
>
<
nav
className=
"space-y-5 overflow-y-auto px-5 pb-6 pt-6"
>
...
...
@@ -351,12 +403,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/analysisService.js
View file @
5a3fb30d
...
...
@@ -14,7 +14,9 @@ import {
import
dayjs
from
'dayjs'
;
export
const
ANALYSIS_VIEW_SUBJECT
=
'analysis_view_config'
;
export
const
ANALYSIS_GROUP_SUBJECT
=
'analysis_view_group'
;
export
const
ANALYSIS_FORWARD_SUBJECT
=
'analysis_forward_record'
;
export
const
ANALYSIS_FORWARD_ITEM_SUBJECT
=
'analysis_forward_record_item'
;
export
const
ANALYSIS_BOARD_SUBJECT
=
'analysis_dashboard_board'
;
export
const
VIEW_SCOPE_OPTIONS
=
[
...
...
@@ -42,18 +44,72 @@ export const APPROVAL_STATUS_OPTIONS = [
{
value
:
'rejected'
,
label
:
'已驳回'
},
];
export
const
EDITOR_STATUS_OPTIONS
=
[
{
value
:
'editing'
,
label
:
'填写中'
},
{
value
:
'completed'
,
label
:
'已完成填写'
},
];
export
const
TRUTH_STATUS_OPTIONS
=
[
{
value
:
'truthful'
,
label
:
'属实'
},
{
value
:
'untruthful'
,
label
:
'不属实'
},
];
export
const
FORWARD_ITEM_FILL_FIELDS
=
[
{
alias
:
'truth_status'
,
label
:
'是否属实'
,
type
:
'select'
,
isRequired
:
true
,
settings
:
{
options
:
TRUTH_STATUS_OPTIONS
,
},
},
{
alias
:
'rectification_plan'
,
label
:
'整改方案'
,
type
:
'long_text'
,
isRequired
:
true
,
},
{
alias
:
'expected_rectification_completed_at'
,
label
:
'预计整改完成时间'
,
type
:
'date'
,
isRequired
:
true
,
},
];
const
ANALYSIS_SYSTEM_SUBJECTS
=
new
Set
([
ANALYSIS_VIEW_SUBJECT
,
ANALYSIS_GROUP_SUBJECT
,
ANALYSIS_FORWARD_SUBJECT
,
ANALYSIS_FORWARD_ITEM_SUBJECT
,
ANALYSIS_BOARD_SUBJECT
,
]);
const
LOCAL_STORAGE_KEYS
=
{
groups
:
'analysis_tool_groups'
,
views
:
'analysis_tool_views'
,
forwards
:
'analysis_tool_forwards'
,
forwardItems
:
'analysis_tool_forward_items'
,
boards
:
'analysis_tool_boards'
,
};
const
DEMO_USER_IDS
=
{
current
:
'64b6575b80bdbd9b3d65d09b'
,
firstApprover
:
'68f59ff3722d1977138f672f'
,
finalApprover
:
'661de69af0e41b429e6a712e'
,
recipientA
:
'649e5a55ad1c1038911baac0'
,
recipientB
:
'69b12a47e92b1de77c94a9f5'
,
};
const
DEMO_USER_NAMES
=
{
current
:
'杨康'
,
firstApprover
:
'杨长青'
,
finalApprover
:
'Alice'
,
recipientA
:
'系统'
,
recipientB
:
'杨康'
,
};
const
FALLBACK_SUBJECT_DEFINITIONS
=
{
am_material
:
{
name
:
'am_material'
,
...
...
@@ -130,7 +186,7 @@ const FALLBACK_SUBJECT_DEFINITIONS = {
description
:
'分析视图配置对象'
,
fields
:
[
{
alias
:
'view_name'
,
label
:
'视图名称'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'subject_name'
,
label
:
'数据源对象'
,
type
:
'
subjec
t'
,
isRequired
:
true
},
{
alias
:
'subject_name'
,
label
:
'数据源对象'
,
type
:
'
tex
t'
,
isRequired
:
true
},
{
alias
:
'description'
,
label
:
'视图说明'
,
type
:
'long_text'
},
{
alias
:
'scope'
,
...
...
@@ -140,10 +196,25 @@ const FALLBACK_SUBJECT_DEFINITIONS = {
options
:
VIEW_SCOPE_OPTIONS
,
},
},
{
alias
:
'filter_fields'
,
label
:
'筛选字段'
,
type
:
'json'
},
{
alias
:
'default_filters'
,
label
:
'默认筛选值'
,
type
:
'json'
},
{
alias
:
'columns'
,
label
:
'结果列'
,
type
:
'json'
},
{
alias
:
'charts'
,
label
:
'图表配置'
,
type
:
'json'
},
{
alias
:
'filter_fields'
,
label
:
'筛选字段'
,
type
:
'text'
,
isMultiple
:
true
},
{
alias
:
'default_filters'
,
label
:
'默认筛选值'
,
type
:
'object'
},
{
alias
:
'columns'
,
label
:
'结果列'
,
type
:
'text'
,
isMultiple
:
true
,
isRequired
:
true
},
{
alias
:
'charts'
,
label
:
'图表配置'
,
type
:
'object'
,
isMultiple
:
true
},
{
alias
:
'group_id'
,
label
:
'所属分组 ID'
,
type
:
'text'
},
{
alias
:
'group_name'
,
label
:
'分组名'
,
type
:
'text'
},
{
alias
:
'tags'
,
label
:
'视图标签'
,
type
:
'text'
,
isMultiple
:
true
},
{
alias
:
'hidden_record_ids'
,
label
:
'手动隐藏记录 ID'
,
type
:
'text'
,
isMultiple
:
true
},
],
},
[
ANALYSIS_GROUP_SUBJECT
]:
{
name
:
ANALYSIS_GROUP_SUBJECT
,
title
:
'分析视图分组'
,
description
:
'分析视图分组树对象'
,
fields
:
[
{
alias
:
'group_name'
,
label
:
'分组名称'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'parent_group_id'
,
label
:
'父分组 ID'
,
type
:
'text'
},
{
alias
:
'created_by_user_id'
,
label
:
'创建人 ID'
,
type
:
'user'
},
{
alias
:
'created_by_user_name'
,
label
:
'创建人名称'
,
type
:
'text'
},
],
},
[
ANALYSIS_FORWARD_SUBJECT
]:
{
...
...
@@ -151,15 +222,45 @@ const FALLBACK_SUBJECT_DEFINITIONS = {
title
:
'转发审批记录'
,
description
:
'分析视图转发与审批记录'
,
fields
:
[
{
alias
:
'view_id'
,
label
:
'来源视图'
,
type
:
'reference'
,
isRequired
:
true
},
{
alias
:
'subject_name'
,
label
:
'数据源对象'
,
type
:
'subject'
,
isRequired
:
true
},
{
alias
:
'selected_record_ids'
,
label
:
'勾选记录'
,
type
:
'json'
,
isRequired
:
true
},
{
alias
:
'recipient_ids'
,
label
:
'转发接收人'
,
type
:
'user'
,
isMultiple
:
true
},
{
alias
:
'view_id'
,
label
:
'来源视图'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'view_name'
,
label
:
'来源视图名称'
,
type
:
'text'
},
{
alias
:
'subject_name'
,
label
:
'数据源对象'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'selected_record_ids'
,
label
:
'勾选记录'
,
type
:
'text'
,
isMultiple
:
true
,
isRequired
:
true
},
{
alias
:
'recipient_ids'
,
label
:
'转发接收人'
,
type
:
'text'
,
isMultiple
:
true
},
{
alias
:
'recipient_names'
,
label
:
'接收人名称'
,
type
:
'text'
,
isMultiple
:
true
},
{
alias
:
'excluded_record_ids'
,
label
:
'不下发记录'
,
type
:
'text'
,
isMultiple
:
true
},
{
alias
:
'note'
,
label
:
'转发说明'
,
type
:
'long_text'
,
isRequired
:
true
},
{
alias
:
'editor_status'
,
label
:
'填写状态'
,
type
:
'select'
},
{
alias
:
'completed_by_id'
,
label
:
'完成人 ID'
,
type
:
'text'
},
{
alias
:
'completed_by_name'
,
label
:
'完成人名称'
,
type
:
'text'
},
{
alias
:
'completed_at'
,
label
:
'完成时间'
,
type
:
'date'
},
{
alias
:
'approval_status'
,
label
:
'审批状态'
,
type
:
'select'
},
{
alias
:
'final_approval_status'
,
label
:
'最终审批状态'
,
type
:
'select'
},
{
alias
:
'first_approver'
,
label
:
'一级审批人'
,
type
:
'user'
,
isRequired
:
true
},
{
alias
:
'final_approver'
,
label
:
'终审人'
,
type
:
'user'
,
isRequired
:
true
},
{
alias
:
'first_approver'
,
label
:
'一级审批人'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'first_approver_name'
,
label
:
'一级审批人名称'
,
type
:
'text'
},
{
alias
:
'final_approver'
,
label
:
'终审人'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'final_approver_name'
,
label
:
'终审人名称'
,
type
:
'text'
},
{
alias
:
'initiator_id'
,
label
:
'发起人 ID'
,
type
:
'text'
},
{
alias
:
'initiator_name'
,
label
:
'发起人名称'
,
type
:
'text'
},
],
},
[
ANALYSIS_FORWARD_ITEM_SUBJECT
]:
{
name
:
ANALYSIS_FORWARD_ITEM_SUBJECT
,
title
:
'转发记录行级回填明细'
,
description
:
'每条下发记录的属实与整改回填数据'
,
fields
:
[
{
alias
:
'forward_record_id'
,
label
:
'来源转发批次 ID'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'forward_record_title'
,
label
:
'来源转发批次名称'
,
type
:
'text'
},
{
alias
:
'view_id'
,
label
:
'来源视图 ID'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'view_name'
,
label
:
'来源视图名称'
,
type
:
'text'
},
{
alias
:
'subject_name'
,
label
:
'数据源对象'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'source_record_id'
,
label
:
'源记录 ID'
,
type
:
'text'
,
isRequired
:
true
},
{
alias
:
'source_record_title'
,
label
:
'源记录标题'
,
type
:
'text'
},
...
FORWARD_ITEM_FILL_FIELDS
,
{
alias
:
'filled_by_id'
,
label
:
'填写人 ID'
,
type
:
'user'
},
{
alias
:
'filled_by_name'
,
label
:
'填写人名称'
,
type
:
'text'
},
{
alias
:
'filled_at'
,
label
:
'填写时间'
,
type
:
'date'
},
{
alias
:
'remark'
,
label
:
'备注'
,
type
:
'long_text'
},
],
},
[
ANALYSIS_BOARD_SUBJECT
]:
{
...
...
@@ -230,12 +331,371 @@ function createLocalRecord(subject, values = {}) {
};
}
function
buildDemoViewRecords
()
{
const
now
=
dayjs
();
return
[
{
_id
:
'demo-analysis-view-1'
,
name
:
'demo_analysis_view_1'
,
title
:
'材料分类概览'
,
subject
:
ANALYSIS_VIEW_SUBJECT
,
metadata
:
{
view_name
:
'材料分类概览'
,
subject_name
:
'am_material'
,
description
:
'按材料分类和上传时间快速查看审计材料分布。'
,
scope
:
'personal'
,
filter_fields
:
[
'archive_category'
,
'tag'
,
'uploaded_at'
,
'uploader'
],
default_filters
:
{},
columns
:
[
'material_title'
,
'archive_category'
,
'tag'
,
'uploader'
,
'uploaded_at'
,
'remark'
],
group_id
:
'demo-group-material-overview'
,
group_name
:
'材料概览'
,
tags
:
[
'分类'
,
'概览'
],
hidden_record_ids
:
[],
charts
:
[
normalizeChartConfig
({
id
:
'demo-chart-view-1'
,
title
:
'材料分类分布'
,
type
:
'bar'
,
dimensionField
:
'archive_category'
,
metricField
:
''
,
aggregation
:
'count'
,
topN
:
8
,
}),
],
created_by_user_id
:
DEMO_USER_IDS
.
current
,
created_by_user_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
5
,
'day'
).
toISOString
(),
updated_at
:
now
.
subtract
(
1
,
'day'
).
toISOString
(),
},
{
_id
:
'demo-analysis-view-2'
,
name
:
'demo_analysis_view_2'
,
title
:
'上传人跟进视图'
,
subject
:
ANALYSIS_VIEW_SUBJECT
,
metadata
:
{
view_name
:
'上传人跟进视图'
,
subject_name
:
'am_material'
,
description
:
'按上传人与标签查看当前材料归集进度。'
,
scope
:
'personal'
,
filter_fields
:
[
'uploader'
,
'tag'
,
'source_app'
],
default_filters
:
{},
columns
:
[
'material_title'
,
'uploader'
,
'tag'
,
'source_app'
,
'uploaded_at'
],
group_id
:
'demo-group-material-progress'
,
group_name
:
'归集进度'
,
tags
:
[
'进度'
,
'上传'
],
hidden_record_ids
:
[],
charts
:
[
normalizeChartConfig
({
id
:
'demo-chart-view-2'
,
title
:
'上传人分布'
,
type
:
'pie'
,
dimensionField
:
'uploader'
,
metricField
:
''
,
aggregation
:
'count'
,
topN
:
6
,
}),
],
created_by_user_id
:
DEMO_USER_IDS
.
current
,
created_by_user_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
4
,
'day'
).
toISOString
(),
updated_at
:
now
.
subtract
(
12
,
'hour'
).
toISOString
(),
},
{
_id
:
'demo-analysis-view-3'
,
name
:
'demo_analysis_view_3'
,
title
:
'材料来源筛查'
,
subject
:
ANALYSIS_VIEW_SUBJECT
,
metadata
:
{
view_name
:
'材料来源筛查'
,
subject_name
:
'am_material'
,
description
:
'聚焦不同来源应用与提供部门的材料结构。'
,
scope
:
'personal'
,
filter_fields
:
[
'source_app'
,
'provide_department'
,
'is_confidential'
],
default_filters
:
{},
columns
:
[
'material_title'
,
'source_app'
,
'provide_department'
,
'is_confidential'
,
'provide_time'
],
group_id
:
'demo-group-material-audit'
,
group_name
:
'重点巡检'
,
tags
:
[
'巡检'
,
'来源'
],
hidden_record_ids
:
[],
charts
:
[
normalizeChartConfig
({
id
:
'demo-chart-view-3'
,
title
:
'来源应用统计'
,
type
:
'bar'
,
dimensionField
:
'source_app'
,
metricField
:
''
,
aggregation
:
'count'
,
topN
:
8
,
}),
],
created_by_user_id
:
DEMO_USER_IDS
.
current
,
created_by_user_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
3
,
'day'
).
toISOString
(),
updated_at
:
now
.
subtract
(
8
,
'hour'
).
toISOString
(),
},
{
_id
:
'demo-analysis-view-4'
,
name
:
'demo_analysis_view_4'
,
title
:
'归档状态巡检'
,
subject
:
ANALYSIS_VIEW_SUBJECT
,
metadata
:
{
view_name
:
'归档状态巡检'
,
subject_name
:
'am_material'
,
description
:
'适合临时筛出待补充说明、待归档标签的材料记录。'
,
scope
:
'personal'
,
filter_fields
:
[
'archive_category'
,
'remark'
,
'is_audit_dept_material'
],
default_filters
:
{},
columns
:
[
'material_title'
,
'archive_category'
,
'remark'
,
'is_audit_dept_material'
,
'uploaded_at'
],
group_id
:
'demo-group-material-audit'
,
group_name
:
'重点巡检'
,
tags
:
[
'巡检'
,
'归档'
],
hidden_record_ids
:
[],
charts
:
[
normalizeChartConfig
({
id
:
'demo-chart-view-4'
,
title
:
'审计部材料占比'
,
type
:
'pie'
,
dimensionField
:
'is_audit_dept_material'
,
metricField
:
''
,
aggregation
:
'count'
,
topN
:
2
,
}),
],
created_by_user_id
:
DEMO_USER_IDS
.
current
,
created_by_user_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
2
,
'day'
).
toISOString
(),
updated_at
:
now
.
subtract
(
4
,
'hour'
).
toISOString
(),
},
];
}
function
buildDemoGroupRecords
()
{
const
now
=
dayjs
();
return
[
createLocalRecord
(
ANALYSIS_GROUP_SUBJECT
,
{
_id
:
'demo-group-material-overview'
,
name
:
'demo_group_material_overview'
,
title
:
'材料概览'
,
metadata
:
{
group_name
:
'材料概览'
,
parent_group_id
:
''
,
created_by_user_id
:
DEMO_USER_IDS
.
current
,
created_by_user_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
5
,
'day'
).
toISOString
(),
updated_at
:
now
.
subtract
(
5
,
'day'
).
toISOString
(),
}),
createLocalRecord
(
ANALYSIS_GROUP_SUBJECT
,
{
_id
:
'demo-group-material-progress'
,
name
:
'demo_group_material_progress'
,
title
:
'归集进度'
,
metadata
:
{
group_name
:
'归集进度'
,
parent_group_id
:
'demo-group-material-overview'
,
created_by_user_id
:
DEMO_USER_IDS
.
current
,
created_by_user_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
4
,
'day'
).
toISOString
(),
updated_at
:
now
.
subtract
(
4
,
'day'
).
toISOString
(),
}),
createLocalRecord
(
ANALYSIS_GROUP_SUBJECT
,
{
_id
:
'demo-group-material-audit'
,
name
:
'demo_group_material_audit'
,
title
:
'重点巡检'
,
metadata
:
{
group_name
:
'重点巡检'
,
parent_group_id
:
'demo-group-material-overview'
,
created_by_user_id
:
DEMO_USER_IDS
.
current
,
created_by_user_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
3
,
'day'
).
toISOString
(),
updated_at
:
now
.
subtract
(
3
,
'day'
).
toISOString
(),
}),
];
}
function
buildDemoForwardRecords
()
{
const
now
=
dayjs
();
return
[
{
_id
:
'demo-forward-1'
,
name
:
'demo_forward_1'
,
title
:
'材料分类概览'
,
subject
:
ANALYSIS_FORWARD_SUBJECT
,
metadata
:
{
view_id
:
'demo-analysis-view-1'
,
view_name
:
'材料分类概览'
,
subject_name
:
'am_material'
,
selected_record_ids
:
[
'am_record_001'
,
'am_record_002'
,
'am_record_003'
],
recipient_ids
:
[
DEMO_USER_IDS
.
recipientA
],
recipient_names
:
[
DEMO_USER_NAMES
.
recipientA
],
excluded_record_ids
:
[
'am_record_004'
],
note
:
'请先确认分类口径,再决定是否继续补充标签。'
,
editor_status
:
'editing'
,
approval_status
:
'pending'
,
final_approval_status
:
'pending'
,
first_approver
:
DEMO_USER_IDS
.
firstApprover
,
first_approver_name
:
DEMO_USER_NAMES
.
firstApprover
,
final_approver
:
DEMO_USER_IDS
.
finalApprover
,
final_approver_name
:
DEMO_USER_NAMES
.
finalApprover
,
initiator_id
:
DEMO_USER_IDS
.
current
,
initiator_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
20
,
'hour'
).
toISOString
(),
updated_at
:
now
.
subtract
(
20
,
'hour'
).
toISOString
(),
},
{
_id
:
'demo-forward-2'
,
name
:
'demo_forward_2'
,
title
:
'上传人跟进视图'
,
subject
:
ANALYSIS_FORWARD_SUBJECT
,
metadata
:
{
view_id
:
'demo-analysis-view-2'
,
view_name
:
'上传人跟进视图'
,
subject_name
:
'am_material'
,
selected_record_ids
:
[
'am_record_011'
,
'am_record_012'
],
recipient_ids
:
[
DEMO_USER_IDS
.
recipientB
],
recipient_names
:
[
DEMO_USER_NAMES
.
recipientB
],
excluded_record_ids
:
[],
note
:
'这批材料已经补齐,请帮忙做一级审批。'
,
editor_status
:
'completed'
,
completed_by_id
:
DEMO_USER_IDS
.
recipientB
,
completed_by_name
:
DEMO_USER_NAMES
.
recipientB
,
completed_at
:
now
.
subtract
(
12
,
'hour'
).
toISOString
(),
approval_status
:
'approved'
,
final_approval_status
:
'pending'
,
first_approver
:
DEMO_USER_IDS
.
firstApprover
,
first_approver_name
:
DEMO_USER_NAMES
.
firstApprover
,
final_approver
:
DEMO_USER_IDS
.
finalApprover
,
final_approver_name
:
DEMO_USER_NAMES
.
finalApprover
,
initiator_id
:
DEMO_USER_IDS
.
current
,
initiator_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
18
,
'hour'
).
toISOString
(),
updated_at
:
now
.
subtract
(
10
,
'hour'
).
toISOString
(),
},
{
_id
:
'demo-forward-3'
,
name
:
'demo_forward_3'
,
title
:
'材料来源筛查'
,
subject
:
ANALYSIS_FORWARD_SUBJECT
,
metadata
:
{
view_id
:
'demo-analysis-view-3'
,
view_name
:
'材料来源筛查'
,
subject_name
:
'am_material'
,
selected_record_ids
:
[
'am_record_021'
,
'am_record_022'
,
'am_record_023'
,
'am_record_024'
],
recipient_ids
:
[
DEMO_USER_IDS
.
recipientA
,
DEMO_USER_IDS
.
recipientB
],
recipient_names
:
[
DEMO_USER_NAMES
.
recipientA
,
DEMO_USER_NAMES
.
recipientB
],
excluded_record_ids
:
[],
note
:
'来源应用字段需要复核,先驳回补资料。'
,
editor_status
:
'completed'
,
completed_by_id
:
DEMO_USER_IDS
.
recipientA
,
completed_by_name
:
DEMO_USER_NAMES
.
recipientA
,
completed_at
:
now
.
subtract
(
14
,
'hour'
).
toISOString
(),
approval_status
:
'rejected'
,
final_approval_status
:
'pending'
,
first_approver
:
DEMO_USER_IDS
.
firstApprover
,
first_approver_name
:
DEMO_USER_NAMES
.
firstApprover
,
final_approver
:
DEMO_USER_IDS
.
finalApprover
,
final_approver_name
:
DEMO_USER_NAMES
.
finalApprover
,
initiator_id
:
DEMO_USER_IDS
.
current
,
initiator_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
15
,
'hour'
).
toISOString
(),
updated_at
:
now
.
subtract
(
13
,
'hour'
).
toISOString
(),
},
{
_id
:
'demo-forward-4'
,
name
:
'demo_forward_4'
,
title
:
'归档状态巡检'
,
subject
:
ANALYSIS_FORWARD_SUBJECT
,
metadata
:
{
view_id
:
'demo-analysis-view-4'
,
view_name
:
'归档状态巡检'
,
subject_name
:
'am_material'
,
selected_record_ids
:
[
'am_record_031'
],
recipient_ids
:
[
DEMO_USER_IDS
.
recipientA
],
recipient_names
:
[
DEMO_USER_NAMES
.
recipientA
],
excluded_record_ids
:
[],
note
:
'终审前请再确认是否涉密字段展示无误。'
,
editor_status
:
'completed'
,
completed_by_id
:
DEMO_USER_IDS
.
recipientA
,
completed_by_name
:
DEMO_USER_NAMES
.
recipientA
,
completed_at
:
now
.
subtract
(
10
,
'hour'
).
toISOString
(),
approval_status
:
'approved'
,
final_approval_status
:
'approved'
,
first_approver
:
DEMO_USER_IDS
.
firstApprover
,
first_approver_name
:
DEMO_USER_NAMES
.
firstApprover
,
final_approver
:
DEMO_USER_IDS
.
finalApprover
,
final_approver_name
:
DEMO_USER_NAMES
.
finalApprover
,
initiator_id
:
DEMO_USER_IDS
.
current
,
initiator_name
:
DEMO_USER_NAMES
.
current
,
},
created_at
:
now
.
subtract
(
12
,
'hour'
).
toISOString
(),
updated_at
:
now
.
subtract
(
6
,
'hour'
).
toISOString
(),
},
{
_id
:
'demo-forward-5'
,
name
:
'demo_forward_5'
,
title
:
'材料分类概览'
,
subject
:
ANALYSIS_FORWARD_SUBJECT
,
metadata
:
{
view_id
:
'demo-analysis-view-1'
,
view_name
:
'材料分类概览'
,
subject_name
:
'am_material'
,
selected_record_ids
:
[
'am_record_041'
,
'am_record_042'
],
recipient_ids
:
[
DEMO_USER_IDS
.
recipientB
],
recipient_names
:
[
DEMO_USER_NAMES
.
recipientB
],
excluded_record_ids
:
[],
note
:
'这条留给终审人模拟待办场景。'
,
editor_status
:
'completed'
,
completed_by_id
:
DEMO_USER_IDS
.
recipientB
,
completed_by_name
:
DEMO_USER_NAMES
.
recipientB
,
completed_at
:
now
.
subtract
(
4
,
'hour'
).
toISOString
(),
approval_status
:
'approved'
,
final_approval_status
:
'pending'
,
first_approver
:
DEMO_USER_IDS
.
firstApprover
,
first_approver_name
:
DEMO_USER_NAMES
.
firstApprover
,
final_approver
:
DEMO_USER_IDS
.
current
,
final_approver_name
:
DEMO_USER_NAMES
.
current
,
initiator_id
:
DEMO_USER_IDS
.
firstApprover
,
initiator_name
:
DEMO_USER_NAMES
.
firstApprover
,
},
created_at
:
now
.
subtract
(
9
,
'hour'
).
toISOString
(),
updated_at
:
now
.
subtract
(
3
,
'hour'
).
toISOString
(),
},
];
}
function
getDemoAnalysisGroups
()
{
return
buildDemoGroupRecords
().
map
(
normalizeGroupRecord
);
}
function
getDemoAnalysisViews
()
{
return
buildDemoViewRecords
().
map
(
normalizeViewRecord
);
}
function
getDemoForwardRecords
()
{
return
buildDemoForwardRecords
().
map
(
normalizeForwardRecord
);
}
function
extractArray
(
response
)
{
const
candidates
=
[
response
,
response
?.
data
,
response
?.
subjects
,
response
?.
data
?.
subjects
,
response
?.
records
,
response
?.
data
?.
records
,
response
?.
users
,
response
?.
data
?.
users
,
response
?.
list
,
response
?.
data
?.
list
,
response
?.
items
,
...
...
@@ -256,8 +716,12 @@ function extractCount(response) {
const
candidates
=
[
response
?.
count
,
response
?.
total
,
response
?.
total_subjects
,
response
?.
total_users
,
response
?.
data
?.
count
,
response
?.
data
?.
total
,
response
?.
data
?.
total_subjects
,
response
?.
data
?.
total_users
,
response
?.
result
?.
count
,
response
?.
result
?.
total
,
];
...
...
@@ -482,6 +946,14 @@ export function formatFieldValue(field, value) {
return
formatDateTime
(
value
);
}
if
(
kind
===
'select'
)
{
return
(
normalizeOptions
(
field
.
settings
?.
options
||
[]).
find
(
(
option
)
=>
option
.
value
===
value
,
)?.
label
||
String
(
value
)
);
}
if
(
kind
===
'json'
||
kind
===
'subject'
)
{
return
typeof
value
===
'string'
?
value
:
JSON
.
stringify
(
value
);
}
...
...
@@ -578,6 +1050,16 @@ function normalizeChartConfig(chart = {}, index = 0) {
function
normalizeViewRecord
(
record
=
{})
{
const
metadata
=
record
.
metadata
||
{};
const
tags
=
Array
.
isArray
(
metadata
.
tags
)
?
metadata
.
tags
.
filter
(
Boolean
)
:
metadata
.
tags
?
[
metadata
.
tags
].
filter
(
Boolean
)
:
[];
const
hiddenRecordIds
=
Array
.
isArray
(
metadata
.
hidden_record_ids
)
?
metadata
.
hidden_record_ids
.
filter
(
Boolean
)
:
metadata
.
hidden_record_ids
?
[
metadata
.
hidden_record_ids
].
filter
(
Boolean
)
:
[];
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
...
...
@@ -597,6 +1079,35 @@ function normalizeViewRecord(record = {}) {
charts
:
Array
.
isArray
(
metadata
.
charts
)
?
metadata
.
charts
.
map
(
normalizeChartConfig
)
:
[],
groupId
:
metadata
.
group_id
||
''
,
groupName
:
metadata
.
group_name
||
''
,
tags
,
hiddenRecordIds
,
createdById
:
metadata
.
created_by_user_id
||
record
.
created_by
?.
_id
||
record
.
created_by
?.
id
||
''
,
createdByName
:
metadata
.
created_by_user_name
||
record
.
created_by
?.
display_name
||
record
.
created_by
?.
username
||
''
,
createdAt
:
record
.
created_at
||
''
,
updatedAt
:
record
.
updated_at
||
''
,
rawRecord
:
record
,
};
}
function
normalizeGroupRecord
(
record
=
{})
{
const
metadata
=
record
.
metadata
||
{};
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
recordName
:
record
.
name
||
record
.
_id
||
record
.
id
||
''
,
title
:
metadata
.
group_name
||
record
.
title
||
'未命名分组'
,
groupName
:
metadata
.
group_name
||
record
.
title
||
'未命名分组'
,
parentGroupId
:
metadata
.
parent_group_id
||
''
,
createdById
:
metadata
.
created_by_user_id
||
record
.
created_by
?.
_id
||
...
...
@@ -635,6 +1146,11 @@ function normalizeForwardRecord(record = {}) {
:
metadata
.
recipient_ids
?
[
metadata
.
recipient_ids
]
:
[];
const
excludedRecordIds
=
Array
.
isArray
(
metadata
.
excluded_record_ids
)
?
metadata
.
excluded_record_ids
:
metadata
.
excluded_record_ids
?
[
metadata
.
excluded_record_ids
]
:
[];
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
...
...
@@ -650,7 +1166,12 @@ function normalizeForwardRecord(record = {}) {
recipientNames
:
Array
.
isArray
(
metadata
.
recipient_names
)
?
metadata
.
recipient_names
:
[],
excludedRecordIds
,
note
:
metadata
.
note
||
''
,
editorStatus
:
metadata
.
editor_status
||
'editing'
,
completedById
:
metadata
.
completed_by_id
||
''
,
completedByName
:
metadata
.
completed_by_name
||
''
,
completedAt
:
metadata
.
completed_at
||
''
,
approvalStatus
:
metadata
.
approval_status
||
'pending'
,
finalApprovalStatus
:
metadata
.
final_approval_status
||
'pending'
,
firstApprover
:
metadata
.
first_approver
||
''
,
...
...
@@ -665,6 +1186,38 @@ function normalizeForwardRecord(record = {}) {
};
}
function
normalizeForwardItemRecord
(
record
=
{})
{
const
metadata
=
record
.
metadata
||
{};
return
{
id
:
record
.
_id
||
record
.
id
||
record
.
name
||
''
,
recordName
:
record
.
name
||
record
.
_id
||
record
.
id
||
''
,
title
:
record
.
title
||
metadata
.
source_record_title
||
metadata
.
forward_record_title
||
'转发回填明细'
,
forwardRecordId
:
metadata
.
forward_record_id
||
''
,
forwardRecordTitle
:
metadata
.
forward_record_title
||
''
,
viewId
:
metadata
.
view_id
||
''
,
viewName
:
metadata
.
view_name
||
''
,
subjectName
:
metadata
.
subject_name
||
''
,
sourceRecordId
:
metadata
.
source_record_id
||
''
,
sourceRecordTitle
:
metadata
.
source_record_title
||
''
,
truthStatus
:
metadata
.
truth_status
||
''
,
rectificationPlan
:
metadata
.
rectification_plan
||
''
,
expectedRectificationCompletedAt
:
metadata
.
expected_rectification_completed_at
||
''
,
filledById
:
metadata
.
filled_by_id
||
''
,
filledByName
:
metadata
.
filled_by_name
||
''
,
filledAt
:
metadata
.
filled_at
||
''
,
remark
:
metadata
.
remark
||
''
,
createdAt
:
record
.
created_at
||
''
,
updatedAt
:
record
.
updated_at
||
''
,
rawRecord
:
record
,
};
}
function
normalizeBoardRecord
(
record
=
{})
{
const
metadata
=
record
.
metadata
||
{};
...
...
@@ -703,6 +1256,14 @@ function createViewPayload(values = {}, currentUser = null) {
charts
:
Array
.
isArray
(
values
.
charts
)
?
values
.
charts
.
map
(
normalizeChartConfig
)
:
[],
group_id
:
String
(
values
.
groupId
||
''
).
trim
(),
group_name
:
String
(
values
.
groupName
||
''
).
trim
(),
tags
:
Array
.
isArray
(
values
.
tags
)
?
values
.
tags
.
map
((
tag
)
=>
String
(
tag
||
''
).
trim
()).
filter
(
Boolean
)
:
[],
hidden_record_ids
:
Array
.
isArray
(
values
.
hiddenRecordIds
)
?
values
.
hiddenRecordIds
.
map
((
id
)
=>
String
(
id
||
''
).
trim
()).
filter
(
Boolean
)
:
[],
created_by_user_id
:
currentUser
?.
_id
||
currentUser
?.
id
||
currentUser
?.
username
||
''
,
created_by_user_name
:
...
...
@@ -732,7 +1293,14 @@ function createForwardPayload(values = {}, currentUser = null) {
recipient_names
:
Array
.
isArray
(
values
.
recipientNames
)
?
values
.
recipientNames
:
[],
excluded_record_ids
:
Array
.
isArray
(
values
.
excludedRecordIds
)
?
values
.
excludedRecordIds
:
[],
note
:
String
(
values
.
note
||
''
).
trim
(),
editor_status
:
values
.
editorStatus
||
'editing'
,
completed_by_id
:
values
.
completedById
||
''
,
completed_by_name
:
values
.
completedByName
||
''
,
completed_at
:
values
.
completedAt
||
''
,
approval_status
:
values
.
approvalStatus
||
'pending'
,
final_approval_status
:
values
.
finalApprovalStatus
||
'pending'
,
first_approver
:
values
.
firstApprover
||
''
,
...
...
@@ -750,6 +1318,65 @@ function createForwardPayload(values = {}, currentUser = null) {
};
}
function
toIsoDateString
(
value
)
{
if
(
!
value
)
{
return
''
;
}
const
parsed
=
dayjs
(
value
);
return
parsed
.
isValid
()
?
parsed
.
toISOString
()
:
String
(
value
);
}
function
createForwardItemPayload
(
values
=
{},
currentUser
=
null
)
{
const
sourceRecordTitle
=
String
(
values
.
sourceRecordTitle
||
values
.
title
||
''
).
trim
()
||
'转发回填明细'
;
const
hasFillContent
=
Boolean
(
values
.
truthStatus
||
String
(
values
.
rectificationPlan
||
''
).
trim
()
||
values
.
expectedRectificationCompletedAt
,
);
const
filledById
=
values
.
filledById
||
(
hasFillContent
?
currentUser
?.
_id
||
currentUser
?.
id
||
currentUser
?.
username
:
''
)
||
''
;
const
filledByName
=
values
.
filledByName
||
(
hasFillContent
?
currentUser
?.
display_name
||
currentUser
?.
displayName
||
currentUser
?.
username
:
''
)
||
''
;
return
{
title
:
sourceRecordTitle
,
subject
:
ANALYSIS_FORWARD_ITEM_SUBJECT
,
content_type
:
'text'
,
metadata
:
{
forward_record_id
:
values
.
forwardRecordId
||
''
,
forward_record_title
:
values
.
forwardRecordTitle
||
''
,
view_id
:
values
.
viewId
||
''
,
view_name
:
values
.
viewName
||
''
,
subject_name
:
values
.
subjectName
||
''
,
source_record_id
:
values
.
sourceRecordId
||
''
,
source_record_title
:
sourceRecordTitle
,
truth_status
:
values
.
truthStatus
||
''
,
rectification_plan
:
String
(
values
.
rectificationPlan
||
''
).
trim
(),
expected_rectification_completed_at
:
toIsoDateString
(
values
.
expectedRectificationCompletedAt
,
),
filled_by_id
:
filledById
,
filled_by_name
:
filledByName
,
filled_at
:
values
.
filledAt
?
toIsoDateString
(
values
.
filledAt
)
:
hasFillContent
?
dayjs
().
toISOString
()
:
''
,
remark
:
String
(
values
.
remark
||
''
).
trim
(),
},
};
}
function
createBoardPayload
(
values
=
{},
currentUser
=
null
)
{
return
{
title
:
String
(
values
.
title
||
''
).
trim
()
||
'未命名看板'
,
...
...
@@ -907,23 +1534,39 @@ export function buildDefaultViewDraft(subjectName, fields = []) {
};
}
export
async
function
loadAnalysis
View
s
()
{
export
async
function
loadAnalysis
Group
s
()
{
try
{
const
response
=
await
queryRecords
({
subject
:
ANALYSIS_
VIEW
_SUBJECT
,
subject
:
ANALYSIS_
GROUP
_SUBJECT
,
page
:
0
,
page_size
:
200
,
sort
:
'
-upd
ated_at'
,
sort
:
'
cre
ated_at'
,
});
return
extractArray
(
response
).
map
(
normalize
View
Record
);
return
extractArray
(
response
).
map
(
normalize
Group
Record
);
}
catch
(
_error
)
{
return
getStoredList
(
LOCAL_STORAGE_KEYS
.
views
).
map
(
normalizeViewRecord
);
const
storedGroups
=
getStoredList
(
LOCAL_STORAGE_KEYS
.
groups
).
map
(
normalizeGroupRecord
);
return
storedGroups
.
length
>
0
?
storedGroups
:
getDemoAnalysisGroups
();
}
}
export
async
function
saveAnalysisView
(
values
,
currentUser
=
null
)
{
const
payload
=
createViewPayload
(
values
,
currentUser
);
export
async
function
saveAnalysisGroup
(
values
,
currentUser
=
null
)
{
const
payload
=
{
title
:
String
(
values
.
groupName
||
values
.
title
||
''
).
trim
()
||
'未命名分组'
,
subject
:
ANALYSIS_GROUP_SUBJECT
,
content_type
:
'text'
,
metadata
:
{
group_name
:
String
(
values
.
groupName
||
values
.
title
||
''
).
trim
()
||
'未命名分组'
,
parent_group_id
:
String
(
values
.
parentGroupId
||
''
).
trim
(),
created_by_user_id
:
currentUser
?.
_id
||
currentUser
?.
id
||
currentUser
?.
username
||
''
,
created_by_user_name
:
currentUser
?.
display_name
||
currentUser
?.
displayName
||
currentUser
?.
username
||
''
,
},
};
try
{
if
(
values
.
recordName
)
{
...
...
@@ -932,34 +1575,34 @@ export async function saveAnalysisView(values, currentUser = null) {
response
?.
data
||
response
?.
result
||
(
await
loadRecord
(
values
.
recordName
));
return
normalize
View
Record
(
record
);
return
normalize
Group
Record
(
record
);
}
const
response
=
await
createRecord
(
payload
);
const
record
=
response
?.
data
||
response
?.
result
||
response
;
return
normalize
View
Record
(
record
);
return
normalize
Group
Record
(
record
);
}
catch
(
_error
)
{
if
(
values
.
recordName
)
{
return
updateLocalRecord
(
ANALYSIS_
VIEW
_SUBJECT
,
ANALYSIS_
GROUP
_SUBJECT
,
values
.
recordName
,
payload
,
LOCAL_STORAGE_KEYS
.
view
s
,
normalize
View
Record
,
LOCAL_STORAGE_KEYS
.
group
s
,
normalize
Group
Record
,
);
}
return
saveLocalRecord
(
ANALYSIS_
VIEW
_SUBJECT
,
ANALYSIS_
GROUP
_SUBJECT
,
payload
,
LOCAL_STORAGE_KEYS
.
view
s
,
normalize
View
Record
,
LOCAL_STORAGE_KEYS
.
group
s
,
normalize
Group
Record
,
);
}
}
export
async
function
deleteAnalysis
View
(
view
)
{
const
recordName
=
view
?.
recordName
||
view
?.
id
;
export
async
function
deleteAnalysis
Group
(
group
)
{
const
recordName
=
group
?.
recordName
||
group
?.
id
;
if
(
!
recordName
)
{
return
;
}
...
...
@@ -967,23 +1610,88 @@ export async function deleteAnalysisView(view) {
try
{
await
removeRecord
(
recordName
);
}
catch
(
_error
)
{
removeLocalRecord
(
recordName
,
LOCAL_STORAGE_KEYS
.
view
s
);
removeLocalRecord
(
recordName
,
LOCAL_STORAGE_KEYS
.
group
s
);
}
}
export
function
buildRecordQueryFilter
(
fields
=
[],
filters
=
{})
{
const
fieldMap
=
new
Map
(
fields
.
map
((
field
)
=>
[
field
.
alias
,
field
]));
const
filter
=
{};
Object
.
entries
(
filters
||
{}).
forEach
(([
alias
,
rawValue
])
=>
{
const
field
=
fieldMap
.
get
(
alias
);
export
async
function
loadAnalysisViews
()
{
try
{
const
response
=
await
queryRecords
({
subject
:
ANALYSIS_VIEW_SUBJECT
,
page
:
0
,
page_size
:
200
,
sort
:
'-updated_at'
,
});
if
(
!
field
)
{
return
;
return
extractArray
(
response
).
map
(
normalizeViewRecord
);
}
catch
(
_error
)
{
const
storedViews
=
getStoredList
(
LOCAL_STORAGE_KEYS
.
views
).
map
(
normalizeViewRecord
);
return
storedViews
.
length
>
0
?
storedViews
:
getDemoAnalysisViews
();
}
}
const
kind
=
getFieldInputKind
(
field
);
const
key
=
`metadata.
${
alias
}
`
;
export
async
function
saveAnalysisView
(
values
,
currentUser
=
null
)
{
const
payload
=
createViewPayload
(
values
,
currentUser
);
try
{
if
(
values
.
recordName
)
{
const
response
=
await
updateRecord
(
values
.
recordName
,
payload
);
const
record
=
response
?.
data
||
response
?.
result
||
(
await
loadRecord
(
values
.
recordName
));
return
normalizeViewRecord
(
record
);
}
const
response
=
await
createRecord
(
payload
);
const
record
=
response
?.
data
||
response
?.
result
||
response
;
return
normalizeViewRecord
(
record
);
}
catch
(
_error
)
{
if
(
values
.
recordName
)
{
return
updateLocalRecord
(
ANALYSIS_VIEW_SUBJECT
,
values
.
recordName
,
payload
,
LOCAL_STORAGE_KEYS
.
views
,
normalizeViewRecord
,
);
}
return
saveLocalRecord
(
ANALYSIS_VIEW_SUBJECT
,
payload
,
LOCAL_STORAGE_KEYS
.
views
,
normalizeViewRecord
,
);
}
}
export
async
function
deleteAnalysisView
(
view
)
{
const
recordName
=
view
?.
recordName
||
view
?.
id
;
if
(
!
recordName
)
{
return
;
}
try
{
await
removeRecord
(
recordName
);
}
catch
(
_error
)
{
removeLocalRecord
(
recordName
,
LOCAL_STORAGE_KEYS
.
views
);
}
}
export
function
buildRecordQueryFilter
(
fields
=
[],
filters
=
{})
{
const
fieldMap
=
new
Map
(
fields
.
map
((
field
)
=>
[
field
.
alias
,
field
]));
const
filter
=
{};
Object
.
entries
(
filters
||
{}).
forEach
(([
alias
,
rawValue
])
=>
{
const
field
=
fieldMap
.
get
(
alias
);
if
(
!
field
)
{
return
;
}
const
kind
=
getFieldInputKind
(
field
);
const
key
=
`metadata.
${
alias
}
`
;
const
value
=
rawValue
;
if
(
...
...
@@ -1086,6 +1794,560 @@ export async function queryAnalysisRecords({
}
}
export
async
function
loadViewRecordCounts
(
views
=
[])
{
const
uniqueViews
=
Array
.
isArray
(
views
)
?
views
.
filter
((
view
)
=>
view
?.
id
)
:
[];
if
(
uniqueViews
.
length
===
0
)
{
return
{};
}
const
uniqueSubjectNames
=
Array
.
from
(
new
Set
(
uniqueViews
.
map
((
view
)
=>
view
.
subjectName
).
filter
(
Boolean
)),
);
const
definitions
=
await
Promise
.
all
(
uniqueSubjectNames
.
map
(
async
(
subjectName
)
=>
[
subjectName
,
await
loadAnalysisSubjectDefinition
(
subjectName
),
]),
);
const
definitionMap
=
new
Map
(
definitions
);
const
settled
=
await
Promise
.
allSettled
(
uniqueViews
.
map
(
async
(
view
)
=>
{
const
definition
=
definitionMap
.
get
(
view
.
subjectName
);
const
filter
=
buildRecordQueryFilter
(
definition
?.
fields
||
[],
view
.
defaultFilters
||
{},
);
const
response
=
await
countRecords
({
subject
:
view
.
subjectName
,
filter
,
});
return
[
view
.
id
,
extractCount
(
response
)];
}),
);
return
settled
.
reduce
((
accumulator
,
item
)
=>
{
if
(
item
.
status
===
'fulfilled'
)
{
const
[
viewId
,
count
]
=
item
.
value
;
accumulator
[
viewId
]
=
Number
.
isFinite
(
Number
(
count
))
?
Number
(
count
)
:
0
;
}
return
accumulator
;
},
{});
}
export
async
function
loadForwardEditableRecords
(
forwardRecord
)
{
if
(
!
forwardRecord
?.
subjectName
)
{
return
{
subjectDefinition
:
null
,
records
:
[],
};
}
const
subjectDefinition
=
await
loadAnalysisSubjectDefinition
(
forwardRecord
.
subjectName
);
const
recordIds
=
Array
.
isArray
(
forwardRecord
.
selectedRecordIds
)
?
forwardRecord
.
selectedRecordIds
.
filter
(
Boolean
)
:
[];
if
(
recordIds
.
length
===
0
)
{
return
{
subjectDefinition
,
records
:
[],
};
}
const
[
recordResults
,
forwardItems
]
=
await
Promise
.
all
([
Promise
.
allSettled
(
recordIds
.
map
((
recordId
)
=>
loadRecord
(
recordId
))),
loadForwardItems
({
forwardRecordId
:
forwardRecord
.
id
}),
]);
const
forwardItemBySourceId
=
new
Map
(
forwardItems
.
filter
((
item
)
=>
item
.
sourceRecordId
)
.
map
((
item
)
=>
[
String
(
item
.
sourceRecordId
),
item
]),
);
const
records
=
recordResults
.
map
((
item
,
index
)
=>
{
const
recordId
=
String
(
recordIds
[
index
]
||
''
);
const
sourceRecord
=
item
.
status
===
'fulfilled'
?
normalizeRecord
(
item
.
value
,
subjectDefinition
?.
fields
||
[])
:
{
id
:
recordId
,
recordName
:
recordId
,
title
:
recordId
,
subject
:
forwardRecord
.
subjectName
,
metadata
:
{},
updatedAt
:
''
,
missing
:
true
,
rawRecord
:
null
,
};
const
sourceRecordId
=
String
(
sourceRecord
.
recordName
||
sourceRecord
.
id
||
recordId
);
const
forwardItem
=
forwardItemBySourceId
.
get
(
sourceRecordId
)
||
null
;
return
{
...
sourceRecord
,
forwardItem
,
forwardItemRecordName
:
forwardItem
?.
recordName
||
''
,
truth_status
:
forwardItem
?.
truthStatus
||
''
,
rectification_plan
:
forwardItem
?.
rectificationPlan
||
''
,
expected_rectification_completed_at
:
forwardItem
?.
expectedRectificationCompletedAt
||
''
,
truthStatus
:
forwardItem
?.
truthStatus
||
''
,
rectificationPlan
:
forwardItem
?.
rectificationPlan
||
''
,
expectedRectificationCompletedAt
:
forwardItem
?.
expectedRectificationCompletedAt
||
''
,
filledById
:
forwardItem
?.
filledById
||
''
,
filledByName
:
forwardItem
?.
filledByName
||
''
,
filledAt
:
forwardItem
?.
filledAt
||
''
,
remark
:
forwardItem
?.
remark
||
''
,
};
});
return
{
subjectDefinition
,
records
,
};
}
export
async
function
loadAnalysisRecordsByIds
(
subjectName
,
recordIds
=
[])
{
if
(
!
subjectName
)
{
return
{
subjectDefinition
:
null
,
records
:
[],
};
}
const
subjectDefinition
=
await
loadAnalysisSubjectDefinition
(
subjectName
);
const
uniqueRecordIds
=
Array
.
from
(
new
Set
((
Array
.
isArray
(
recordIds
)
?
recordIds
:
[]).
filter
(
Boolean
).
map
((
id
)
=>
String
(
id
))),
);
if
(
uniqueRecordIds
.
length
===
0
)
{
return
{
subjectDefinition
,
records
:
[],
};
}
const
settled
=
await
Promise
.
allSettled
(
uniqueRecordIds
.
map
((
recordId
)
=>
loadRecord
(
recordId
)),
);
const
records
=
settled
.
map
((
item
,
index
)
=>
{
const
recordId
=
uniqueRecordIds
[
index
];
if
(
item
.
status
===
'fulfilled'
)
{
return
normalizeRecord
(
item
.
value
,
subjectDefinition
?.
fields
||
[]);
}
return
{
id
:
recordId
,
recordName
:
recordId
,
title
:
recordId
,
subject
:
subjectName
,
metadata
:
{},
updatedAt
:
''
,
missing
:
true
,
rawRecord
:
null
,
};
});
return
{
subjectDefinition
,
records
,
};
}
export
async
function
loadForwardItems
(
options
=
{})
{
const
filter
=
{};
if
(
options
.
forwardRecordId
)
{
filter
[
'metadata.forward_record_id'
]
=
options
.
forwardRecordId
;
}
if
(
options
.
viewId
)
{
filter
[
'metadata.view_id'
]
=
options
.
viewId
;
}
if
(
Array
.
isArray
(
options
.
sourceRecordIds
)
&&
options
.
sourceRecordIds
.
length
>
0
)
{
filter
[
'metadata.source_record_id'
]
=
{
$in
:
options
.
sourceRecordIds
.
map
((
id
)
=>
String
(
id
)),
};
}
try
{
const
response
=
await
queryRecords
({
subject
:
ANALYSIS_FORWARD_ITEM_SUBJECT
,
page
:
0
,
page_size
:
500
,
sort
:
'-updated_at'
,
filter
,
});
return
extractArray
(
response
).
map
(
normalizeForwardItemRecord
);
}
catch
(
_error
)
{
const
items
=
getStoredList
(
LOCAL_STORAGE_KEYS
.
forwardItems
).
map
(
normalizeForwardItemRecord
,
);
return
items
.
filter
((
item
)
=>
{
if
(
options
.
forwardRecordId
&&
item
.
forwardRecordId
!==
options
.
forwardRecordId
)
{
return
false
;
}
if
(
options
.
viewId
&&
item
.
viewId
!==
options
.
viewId
)
{
return
false
;
}
if
(
Array
.
isArray
(
options
.
sourceRecordIds
)
&&
options
.
sourceRecordIds
.
length
>
0
&&
!
options
.
sourceRecordIds
.
map
((
id
)
=>
String
(
id
)).
includes
(
item
.
sourceRecordId
)
)
{
return
false
;
}
return
true
;
});
}
}
export
async
function
saveForwardItemRecord
(
values
=
{},
currentUser
=
null
)
{
const
payload
=
createForwardItemPayload
(
values
,
currentUser
);
try
{
if
(
values
.
recordName
)
{
const
response
=
await
updateRecord
(
values
.
recordName
,
payload
);
const
record
=
response
?.
data
||
response
?.
result
||
(
await
loadRecord
(
values
.
recordName
));
return
normalizeForwardItemRecord
(
record
);
}
const
response
=
await
createRecord
(
payload
);
const
record
=
response
?.
data
||
response
?.
result
||
response
;
return
normalizeForwardItemRecord
(
record
);
}
catch
(
_error
)
{
if
(
values
.
recordName
)
{
return
updateLocalRecord
(
ANALYSIS_FORWARD_ITEM_SUBJECT
,
values
.
recordName
,
payload
,
LOCAL_STORAGE_KEYS
.
forwardItems
,
normalizeForwardItemRecord
,
);
}
return
saveLocalRecord
(
ANALYSIS_FORWARD_ITEM_SUBJECT
,
payload
,
LOCAL_STORAGE_KEYS
.
forwardItems
,
normalizeForwardItemRecord
,
);
}
}
export
async
function
ensureForwardRecordItems
(
forwardRecord
,
sourceRecords
=
[],
currentUser
=
null
,
)
{
if
(
!
forwardRecord
?.
id
)
{
return
[];
}
const
selectedRecordIds
=
Array
.
isArray
(
forwardRecord
.
selectedRecordIds
)
?
forwardRecord
.
selectedRecordIds
.
map
((
id
)
=>
String
(
id
)).
filter
(
Boolean
)
:
[];
if
(
selectedRecordIds
.
length
===
0
)
{
return
[];
}
const
existingItems
=
await
loadForwardItems
({
forwardRecordId
:
forwardRecord
.
id
});
const
existingSourceIdSet
=
new
Set
(
existingItems
.
map
((
item
)
=>
String
(
item
.
sourceRecordId
)).
filter
(
Boolean
),
);
const
sourceRecordById
=
new
Map
(
(
Array
.
isArray
(
sourceRecords
)
?
sourceRecords
:
[]).
map
((
record
)
=>
[
String
(
record
.
recordName
||
record
.
id
||
''
),
record
,
]),
);
const
missingRecordIds
=
selectedRecordIds
.
filter
(
(
recordId
)
=>
!
existingSourceIdSet
.
has
(
recordId
),
);
if
(
missingRecordIds
.
length
===
0
)
{
return
existingItems
;
}
const
createdItems
=
await
Promise
.
all
(
missingRecordIds
.
map
((
sourceRecordId
)
=>
{
const
sourceRecord
=
sourceRecordById
.
get
(
sourceRecordId
);
return
saveForwardItemRecord
(
{
forwardRecordId
:
forwardRecord
.
id
,
forwardRecordTitle
:
forwardRecord
.
title
||
forwardRecord
.
viewName
||
''
,
viewId
:
forwardRecord
.
viewId
||
''
,
viewName
:
forwardRecord
.
viewName
||
''
,
subjectName
:
forwardRecord
.
subjectName
||
''
,
sourceRecordId
,
sourceRecordTitle
:
sourceRecord
?.
title
||
sourceRecord
?.
sourceRecordTitle
||
sourceRecordId
,
},
currentUser
,
);
}),
);
return
[...
existingItems
,
...
createdItems
];
}
export
async
function
saveForwardEditableRecords
(
forwardRecord
,
records
=
[],
currentUser
=
null
,
)
{
if
(
!
forwardRecord
?.
id
)
{
throw
new
Error
(
'缺少转发批次标识'
);
}
const
results
=
await
Promise
.
all
(
records
.
map
((
record
)
=>
saveForwardItemRecord
(
{
recordName
:
record
.
forwardItemRecordName
,
forwardRecordId
:
forwardRecord
.
id
,
forwardRecordTitle
:
forwardRecord
.
title
||
forwardRecord
.
viewName
||
''
,
viewId
:
forwardRecord
.
viewId
||
''
,
viewName
:
forwardRecord
.
viewName
||
''
,
subjectName
:
forwardRecord
.
subjectName
||
''
,
sourceRecordId
:
record
.
recordName
||
record
.
id
||
''
,
sourceRecordTitle
:
record
.
title
||
record
.
recordName
||
record
.
id
||
''
,
truthStatus
:
record
.
truthStatus
||
record
.
truth_status
||
''
,
rectificationPlan
:
record
.
rectificationPlan
||
record
.
rectification_plan
||
''
,
expectedRectificationCompletedAt
:
record
.
expectedRectificationCompletedAt
||
record
.
expected_rectification_completed_at
||
''
,
remark
:
record
.
remark
||
''
,
},
currentUser
,
),
),
);
const
resultBySourceId
=
new
Map
(
results
.
map
((
item
)
=>
[
String
(
item
.
sourceRecordId
),
item
]),
);
return
records
.
map
((
record
)
=>
{
const
sourceRecordId
=
String
(
record
.
recordName
||
record
.
id
||
''
);
const
forwardItem
=
resultBySourceId
.
get
(
sourceRecordId
)
||
record
.
forwardItem
;
return
{
...
record
,
forwardItem
,
forwardItemRecordName
:
forwardItem
?.
recordName
||
''
,
truth_status
:
forwardItem
?.
truthStatus
||
''
,
rectification_plan
:
forwardItem
?.
rectificationPlan
||
''
,
expected_rectification_completed_at
:
forwardItem
?.
expectedRectificationCompletedAt
||
''
,
truthStatus
:
forwardItem
?.
truthStatus
||
''
,
rectificationPlan
:
forwardItem
?.
rectificationPlan
||
''
,
expectedRectificationCompletedAt
:
forwardItem
?.
expectedRectificationCompletedAt
||
''
,
filledById
:
forwardItem
?.
filledById
||
''
,
filledByName
:
forwardItem
?.
filledByName
||
''
,
filledAt
:
forwardItem
?.
filledAt
||
''
,
remark
:
forwardItem
?.
remark
||
''
,
};
});
}
export
function
getTruthStatusLabel
(
status
)
{
return
(
TRUTH_STATUS_OPTIONS
.
find
((
option
)
=>
option
.
value
===
status
)?.
label
||
status
||
'-'
);
}
export
function
buildForwardItemIndex
(
forwardItems
=
[])
{
const
sorted
=
[...
forwardItems
].
sort
((
left
,
right
)
=>
String
(
right
.
updatedAt
||
right
.
createdAt
).
localeCompare
(
String
(
left
.
updatedAt
||
left
.
createdAt
),
),
);
const
latestItemByRecordId
=
new
Map
();
sorted
.
forEach
((
item
)
=>
{
const
sourceRecordId
=
String
(
item
.
sourceRecordId
||
''
);
if
(
sourceRecordId
&&
!
latestItemByRecordId
.
has
(
sourceRecordId
))
{
latestItemByRecordId
.
set
(
sourceRecordId
,
item
);
}
});
return
latestItemByRecordId
;
}
export
function
findIncompleteForwardItemRecords
(
records
=
[])
{
return
records
.
filter
(
(
record
)
=>
!
(
record
.
truthStatus
||
record
.
truth_status
)
||
!
String
(
record
.
rectificationPlan
||
record
.
rectification_plan
||
''
).
trim
()
||
!
(
record
.
expectedRectificationCompletedAt
||
record
.
expected_rectification_completed_at
),
);
}
export
function
isForwardRecordClosed
(
forwardRecord
=
{})
{
return
(
forwardRecord
.
approvalStatus
===
'rejected'
||
forwardRecord
.
finalApprovalStatus
===
'approved'
||
forwardRecord
.
finalApprovalStatus
===
'rejected'
);
}
export
function
resolveForwardRowStatus
(
forwardRecord
=
null
)
{
if
(
!
forwardRecord
)
{
return
'pending_send'
;
}
if
(
forwardRecord
.
editorStatus
!==
'completed'
)
{
return
'in_progress'
;
}
if
(
forwardRecord
.
approvalStatus
===
'rejected'
||
forwardRecord
.
finalApprovalStatus
===
'rejected'
)
{
return
'rejected'
;
}
if
(
forwardRecord
.
approvalStatus
===
'pending'
)
{
return
'pending_approval'
;
}
if
(
forwardRecord
.
finalApprovalStatus
===
'approved'
)
{
return
'final_approved'
;
}
if
(
forwardRecord
.
approvalStatus
===
'approved'
)
{
return
'approved'
;
}
return
'in_progress'
;
}
export
function
getForwardRowStatusLabel
(
status
)
{
const
labels
=
{
pending_send
:
'待发送'
,
in_progress
:
'进行中'
,
pending_approval
:
'待审批'
,
approved
:
'已审批'
,
final_approved
:
'已最终审批'
,
rejected
:
'已驳回'
,
};
return
labels
[
status
]
||
status
;
}
export
function
getForwardRowStatusTone
(
status
)
{
const
tones
=
{
pending_send
:
'default'
,
in_progress
:
'amber'
,
pending_approval
:
'sky'
,
approved
:
'sky'
,
final_approved
:
'emerald'
,
rejected
:
'rose'
,
};
return
tones
[
status
]
||
'default'
;
}
export
function
getEditorStatusLabel
(
status
)
{
return
(
EDITOR_STATUS_OPTIONS
.
find
((
option
)
=>
option
.
value
===
status
)?.
label
||
status
);
}
export
function
buildForwardStatusIndex
(
forwardRecords
=
[])
{
const
sorted
=
[...
forwardRecords
].
sort
((
left
,
right
)
=>
String
(
right
.
updatedAt
||
right
.
createdAt
).
localeCompare
(
String
(
left
.
updatedAt
||
left
.
createdAt
),
),
);
const
latestForwardByRecordId
=
new
Map
();
const
hiddenRecordIdSet
=
new
Set
();
sorted
.
forEach
((
forwardRecord
)
=>
{
const
isClosed
=
isForwardRecordClosed
(
forwardRecord
);
if
(
!
isClosed
)
{
(
forwardRecord
.
excludedRecordIds
||
[]).
forEach
((
recordId
)
=>
{
if
(
recordId
)
{
hiddenRecordIdSet
.
add
(
String
(
recordId
));
}
});
}
(
forwardRecord
.
selectedRecordIds
||
[]).
forEach
((
recordId
)
=>
{
const
key
=
String
(
recordId
||
''
);
if
(
key
&&
!
latestForwardByRecordId
.
has
(
key
))
{
latestForwardByRecordId
.
set
(
key
,
forwardRecord
);
}
});
});
return
{
hiddenRecordIdSet
,
latestForwardByRecordId
,
};
}
export
function
buildGroupTree
(
groups
=
[])
{
const
map
=
new
Map
();
groups
.
forEach
((
group
)
=>
{
if
(
!
group
?.
id
)
{
return
;
}
map
.
set
(
group
.
id
,
{
...
group
,
children
:
[],
});
});
const
roots
=
[];
map
.
forEach
((
group
)
=>
{
if
(
group
.
parentGroupId
&&
map
.
has
(
group
.
parentGroupId
))
{
map
.
get
(
group
.
parentGroupId
).
children
.
push
(
group
);
return
;
}
roots
.
push
(
group
);
});
const
sortGroups
=
(
items
)
=>
{
items
.
sort
((
left
,
right
)
=>
String
(
left
.
groupName
||
left
.
title
).
localeCompare
(
String
(
right
.
groupName
||
right
.
title
),
'zh-CN'
,
),
);
items
.
forEach
((
item
)
=>
sortGroups
(
item
.
children
));
return
items
;
};
return
sortGroups
(
roots
);
}
export
function
buildMetricCards
({
subjects
=
[],
views
=
[],
forwards
=
[],
boards
=
[]
})
{
const
pendingApprovals
=
forwards
.
filter
(
(
item
)
=>
...
...
@@ -1206,18 +2468,32 @@ export async function loadUsersByIds(ids = []) {
.
filter
((
user
)
=>
user
.
id
);
}
export
async
function
loadForwardRecords
()
{
export
async
function
loadForwardRecords
(
options
=
{})
{
const
filter
=
{};
if
(
options
.
viewId
)
{
filter
[
'metadata.view_id'
]
=
options
.
viewId
;
}
try
{
const
response
=
await
queryRecords
({
subject
:
ANALYSIS_FORWARD_SUBJECT
,
page
:
0
,
page_size
:
200
,
sort
:
'-updated_at'
,
filter
,
});
return
extractArray
(
response
).
map
(
normalizeForwardRecord
);
}
catch
(
_error
)
{
return
getStoredList
(
LOCAL_STORAGE_KEYS
.
forwards
).
map
(
normalizeForwardRecord
);
const
storedForwards
=
getStoredList
(
LOCAL_STORAGE_KEYS
.
forwards
).
map
(
normalizeForwardRecord
);
const
fallback
=
storedForwards
.
length
>
0
?
storedForwards
:
getDemoForwardRecords
();
if
(
options
.
viewId
)
{
return
fallback
.
filter
((
item
)
=>
item
.
viewId
===
options
.
viewId
);
}
return
fallback
;
}
}
...
...
@@ -1245,16 +2521,46 @@ export async function updateForwardRecordStatus(recordName, nextMetadata = {}) {
try
{
const
currentRecord
=
await
loadRecord
(
recordName
);
const
currentForward
=
normalizeForwardRecord
(
currentRecord
);
const
payload
=
{
title
:
currentRecord
?.
title
||
'分析转发记录'
,
title
:
currentRecord
?.
title
||
currentForward
.
title
||
'分析转发记录'
,
subject
:
ANALYSIS_FORWARD_SUBJECT
,
content_type
:
'text'
,
metadata
:
{
...(
currentRecord
?.
metadata
||
{}),
...
nextMetadata
,
view_id
:
currentForward
.
viewId
||
''
,
view_name
:
currentForward
.
viewName
||
''
,
subject_name
:
currentForward
.
subjectName
||
''
,
selected_record_ids
:
currentForward
.
selectedRecordIds
||
[],
recipient_ids
:
currentForward
.
recipientIds
||
[],
recipient_names
:
currentForward
.
recipientNames
||
[],
excluded_record_ids
:
currentForward
.
excludedRecordIds
||
[],
note
:
currentForward
.
note
||
''
,
editor_status
:
nextMetadata
.
editor_status
??
currentForward
.
editorStatus
??
'editing'
,
completed_by_id
:
nextMetadata
.
completed_by_id
??
currentForward
.
completedById
??
''
,
completed_by_name
:
nextMetadata
.
completed_by_name
??
currentForward
.
completedByName
??
''
,
completed_at
:
nextMetadata
.
completed_at
??
currentForward
.
completedAt
??
''
,
approval_status
:
nextMetadata
.
approval_status
??
currentForward
.
approvalStatus
??
'pending'
,
final_approval_status
:
nextMetadata
.
final_approval_status
??
currentForward
.
finalApprovalStatus
??
'pending'
,
first_approver
:
currentForward
.
firstApprover
||
''
,
first_approver_name
:
currentForward
.
firstApproverName
||
''
,
final_approver
:
currentForward
.
finalApprover
||
''
,
final_approver_name
:
currentForward
.
finalApproverName
||
''
,
initiator_id
:
currentForward
.
initiatorId
||
''
,
initiator_name
:
currentForward
.
initiatorName
||
''
,
},
};
const
response
=
await
updateRecord
(
recordName
,
payload
);
return
normalizeForwardRecord
(
response
?.
data
||
response
?.
result
||
payload
);
return
normalizeForwardRecord
(
response
?.
data
||
response
?.
result
||
(
await
loadRecord
(
recordName
))
||
payload
,
);
}
catch
(
_error
)
{
const
existing
=
getStoredList
(
LOCAL_STORAGE_KEYS
.
forwards
);
const
target
=
existing
.
find
((
item
)
=>
(
item
.
name
||
item
.
_id
)
===
recordName
);
...
...
@@ -1638,6 +2944,7 @@ export function createEmptyForwardDraft() {
return
{
recipientIds
:
[],
recipientNames
:
[],
excludedRecordIds
:
[],
note
:
''
,
firstApprover
:
null
,
firstApproverName
:
''
,
...
...
src/pages/project_manager_xy/audit-material-center.business-flow.md
View file @
5a3fb30d
...
...
@@ -5,6 +5,7 @@
本模块已从“审计材料中心”重构为“配置驱动的数据分析工具”,核心目标如下:
-
让业务人员可以针对任意业务对象快速搭建单对象分析视图
-
支持以无限级分组树管理分析视图,并通过标签快速检索
-
在分析视图中同时完成筛选、表格查看、图表分析与记录勾选
-
支持把勾选结果转发给其他人,并补充转发记录数据后进入两级顺序审批
-
支持将多个图表组合成独立看板,对不同角色输出隔离的分析展示页
...
...
@@ -15,6 +16,8 @@
-
所有配置与审批记录都走独立 Record 对象,统一写入
`metadata`
-
图表看板和分析视图配置隔离,但允许复用同一数据源与同一图表定义
-
分析视图前端不再区分个人视图 / 共享视图,历史
`scope`
字段仅作为兼容字段保留
-
表格逐行状态根据当前视图下的转发审批批次记录映射得出
-
每条下发记录的“是否属实、整改方案、预计整改完成时间”通过
`analysis_forward_record_item`
独立保存,不写入源对象字段
---
...
...
@@ -27,21 +30,22 @@
-
`转发审批`
-
`图表看板`
菜单栏左上角提供“应用切换”入口,点击后以弹框展示当前账号可访问的全部应用;桌面端在侧栏展开和收起状态均可进入,移动端在顶部导航栏进入,点击应用卡片后跳转到对应应用工作区。
推荐职责划分:
-
`分析总览`
-
展示
对象覆盖
数、视图数、转发审批数、图表看板数
-
展示
可分析对象
数、视图数、转发审批数、图表看板数
-
展示对象视图分布、审批状态分布、看板共享情况
-
展示最近配置活动
-
`分析视图`
-
选择对象
-
维护视图分组树
-
新建、编辑、删除分析视图
-
查看当前分组及其子分组下的全部视图
-
运行动态筛选、查看结果表格、查看图表
-
在结果区勾选记录后发起转发审批
-
`转发审批`
-
展示我的发起、待我审批、我参与的转发记录
-
推进一级审批状态与最终审批状态
-
支持手工补录转发审批
-
`图表看板`
-
新建、编辑、删除看板
-
将多个视图图表组合到同一看板
...
...
@@ -52,7 +56,9 @@
## 3. 核心业务对象
-
Subject:
`analysis_view_config`
-
Subject:
`analysis_view_group`
-
Subject:
`analysis_forward_record`
-
Subject:
`analysis_forward_record_item`
-
Subject:
`analysis_dashboard_board`
-
数据源对象:当前至少支持
`am_material`
...
...
@@ -65,15 +71,36 @@
-
`default_filters`
-
`columns`
-
`charts`
-
`group_id`
-
`group_name`
-
`tags`
-
`hidden_record_ids`
-
`analysis_view_group`
-
`group_name`
-
`parent_group_id`
-
`analysis_forward_record`
-
`view_id`
-
`selected_record_ids`
-
`excluded_record_ids`
-
`recipient_ids`
-
`note`
-
`editor_status`
-
`completed_by_id`
-
`completed_at`
-
`approval_status`
-
`final_approval_status`
-
`first_approver`
-
`final_approver`
-
`analysis_forward_record_item`
-
`forward_record_id`
-
`view_id`
-
`subject_name`
-
`source_record_id`
-
`truth_status`
-
`rectification_plan`
-
`expected_rectification_completed_at`
-
`filled_by_id`
-
`filled_at`
-
`analysis_dashboard_board`
-
`board_name`
-
`scope`
...
...
@@ -86,68 +113,129 @@
### 4.1 创建分析视图
1.
用户进入
`分析视图`
2.
点击“新建视图”
2.
在左侧分组树中选择一个已有分组,或先创建新分组
3.
点击“新建视图”
3.
在弹窗中通过对象下拉搜索框选择一个业务对象
-
对象列表默认只加载前 10 条
-
用户输入关键词后通过接口搜索对象名称或对象标识
4.
填写视图名称、视图说明
5.
选择要暴露给业务使用的筛选字段
6.
选择结果表格列
7.
配置 1-N 个图表卡片:
5.
选择所属分组,录入 0-N 个视图标签
6.
选择要暴露给业务使用的筛选字段
7.
选择结果表格列
8.
配置 1-N 个图表卡片:
-
图表类型
-
维度字段
-
指标字段
-
Top N
-
说明文本
8
.
可删除任意一张图表配置卡片,不影响其它图表配置
9
.
如有需要,补充默认筛选值
1
0
.
点击保存后创建
`analysis_view_config`
9
.
可删除任意一张图表配置卡片,不影响其它图表配置
10
.
如有需要,补充默认筛选值
1
1
.
点击保存后创建
`analysis_view_config`
### 4.2
使用分析视图查看数据
### 4.2
管理视图分组
1.
用户进入
`分析视图`
2.
页面左侧直接展示全部分析视图列表,不要求先选择对象
3.
用户可以通过视图搜索框按视图名称、说明或绑定对象快速定位
4.
在左侧纵向滚动视图列表中快速浏览和切换视图
-
视图卡片展示名称、说明、绑定对象、列数、筛选字段数和图表数
-
列表固定高度并支持上下滚动,不随视图数量拉长页面
5.
选择视图后,右侧自动加载该视图绑定对象并按视图配置动态渲染筛选区
6.
用户修改筛选条件并刷新结果
7.
系统查询对象数据并刷新结果区
8.
结果区使用 Tab 分离:
2.
左侧展示
`全部视图`
根节点和无限级分组树
3.
用户可对分组执行:
-
新建同级分组
-
新建子分组
-
重命名分组
-
删除空分组
4.
菜单中只展示分组名,不直接展示分组下的视图
5.
删除分组时,若仍存在子分组或视图,则需先迁移后再删除
### 4.3 使用分析视图查看数据
1.
用户进入
`分析视图`
2.
页面左侧先选择某个分组节点
3.
右侧先展示当前分组及其所有子分组下的全部分析视图
4.
桌面端左侧分组树在页面内固定显示,不随右侧内容区滚动而离开视口
5.
当左侧分组树节点过多时,分组树区域自身出现滚动条,避免拉长整页
6.
用户可以通过搜索和标签快速定位视图
7.
视图列表展示名称、说明、绑定对象、所属分组、标签、当前命中数据量、结果列数、筛选数和图表数
8.
选择视图后,右侧自动加载该视图绑定对象并按视图配置动态渲染筛选区
9.
用户修改筛选条件并刷新结果
10.
系统查询对象数据并刷新结果区
11.
结果区使用 Tab 分离:
-
`表格`
:展示查询结果,支持勾选记录并发起转发审批
-
`图表`
:展示当前视图配置的 1-N 个 ECharts 图表
9.
表格区域固定高度并在内部滚动,避免列表较长时拉长整个页面
10.
结果区支持全屏 / 缩小,用于临时放大表格或图表查看空间
11.
用户可以继续切换视图,而不同视图配置互不影响
### 4.3 在视图中转发记录并发起审批
12.
表格区域固定高度并在内部滚动,避免列表较长时拉长整个页面
13.
表格中每条记录都展示当前状态:
-
`待发送`
-
`进行中`
-
`待审批`
-
`已审批`
-
`已最终审批`
-
`已驳回`
14.
表格标题列可点击,点击后打开当前记录的回填详情弹窗
15.
回填详情弹窗展示是否属实、整改方案、预计整改完成时间、填写人、填写时间和来源批次
16.
用户可在结果区勾选 1-N 条记录后执行“隐藏所选”
17.
被手动隐藏的记录需要写入当前视图的
`hidden_record_ids`
18.
用户再次进入同一个视图时,这些手动隐藏记录默认不展示
19.
页面需提供“管理已隐藏”入口,支持查看已隐藏记录并勾选恢复
20.
转发出去的记录不会因为转发动作自动从视图中消失
21.
结果区支持全屏 / 缩小,用于临时放大表格或图表查看空间
22.
用户可以继续切换视图,而不同视图配置互不影响
### 4.4 在视图中转发记录并发起审批
1.
用户在分析视图结果区勾选 1-N 条记录
2.
点击“转发勾选记录”
3.
在弹窗中填写:
3.
在弹窗中区分:
-
本次需要下发的记录
-
本次明确不下发的记录
4.
在弹窗中填写:
-
接收人
-
转发说明 / 补充记录
-
一级审批人
-
终审人
4.
提交后创建
`analysis_forward_record`
5.
记录初始化状态:
5.
提交后创建
`analysis_forward_record`
6.
为本次实际下发的每条源记录创建一条
`analysis_forward_record_item`
7.
批次记录初始化状态:
-
`editor_status = editing`
-
`approval_status = pending`
-
`final_approval_status = pending`
### 4.4 推进两级审批
8.
`selected_record_ids`
保存本次实际下发的记录
9.
`excluded_record_ids`
保存本次选中但明确不下发的记录
10.
当前批次未结束前,命中
`excluded_record_ids`
的记录在原视图表格中不展示
11.
本次实际下发的记录不会因为转发动作自动从视图中消失
### 4.5 接收人填写并提交
1.
接收人进入
`转发审批`
2.
在“我参与的”视角看到自己参与的批次
3.
选中某个仍处于
`editor_status = editing`
的批次
4.
点击右侧“进入填写”
5.
页面加载
`selected_record_ids`
对应的原始业务记录作为只读摘要,并加载对应
`analysis_forward_record_item`
6.
接收人逐条填写:
-
是否属实
`truth_status`
-
整改方案
`rectification_plan`
-
预计整改完成时间
`expected_rectification_completed_at`
7.
接收人可多次保存行级回填,只要尚未点击“完成填写”,该批次始终视为“进行中”
8.
点击“完成填写”前,系统校验每条下发记录的 3 个回填字段均已填写
9.
任意一位接收人点击“完成填写”后,系统同步写入:
-
`editor_status = completed`
-
`completed_by_id`
-
`completed_by_name`
-
`completed_at`
10.
一旦完成填写,该批次对应行级回填数据立即锁定,不再允许其他接收人继续修改
11.
填写人、一级审批人、终审人都可以继续查看这批次源记录摘要和行级回填,但此时仅保留查看权限
12.
批次状态从“进行中”切换为真正的“待审批”
### 4.6 推进两级审批
1.
一级审批人进入
`转发审批`
2.
在“待我审批”视角看到待办记录
3.
一级审批人执行通过或驳回:
3.
只有
`editor_status = completed`
的批次才能出现在待审批列表中
4.
一级审批人执行通过或驳回:
-
通过:
`approval_status = approved`
-
驳回:
`approval_status = rejected`
4
.
只有当一级审批通过后,终审人才能推进最终审批状态
5
.
终审人执行通过或驳回:
5
.
只有当一级审批通过后,终审人才能推进最终审批状态
6
.
终审人执行通过或驳回:
-
通过:
`final_approval_status = approved`
-
驳回:
`final_approval_status = rejected`
### 4.
5
组合图表看板
### 4.
7
组合图表看板
1.
用户进入
`图表看板`
2.
点击“新建看板”
...
...
@@ -180,9 +268,22 @@
### 5.3 转发审批与视图的关系
-
转发必须从某个分析视图结果区发起
,或通过审批页手工补录
-
转发必须从某个分析视图结果区发起
-
转发记录必须能追溯到来源视图
-
审批页更新状态时,不反向修改原始业务对象数据
-
审批页更新状态和行级回填时,不反向修改原始业务对象数据
-
接收人编辑入口位于
`转发审批 -> 我参与的 -> 进入填写`
-
接收人编辑的是
`analysis_forward_record_item`
行级回填明细,
`selected_record_ids`
对应的源记录仅用于只读摘要展示
-
视图里的“手动隐藏”能力独立于转发审批,持久化在
`analysis_view_config.hidden_record_ids`
-
被手动隐藏的记录再次进入同一个视图时默认不展示,但可在“管理已隐藏”中恢复
-
行级回填字段按当前视图下最新命中的
`analysis_forward_record_item`
展示
-
表格逐行状态按当前视图下最新命中的
`analysis_forward_record`
批次计算
-
对某条记录的状态判断规则:
-
未命中任何批次:
`待发送`
-
命中批次且
`editor_status = editing`
:
`进行中`
-
命中批次且
`editor_status = completed`
、
`approval_status = pending`
:
`待审批`
-
命中批次且
`approval_status = approved`
、
`final_approval_status = pending`
:
`已审批`
-
命中批次且
`final_approval_status = approved`
:
`已最终审批`
-
命中批次且任一审批状态为
`rejected`
:
`已驳回`
---
...
...
@@ -199,7 +300,6 @@
-
对象视图分布
-
审批状态分布
-
看板共享情况
-
最近活动
### 6.2 分析视图
...
...
@@ -207,13 +307,17 @@
-
紧凑操作入口
-
不使用大面积 Hero / 截图说明区域
-
新建入口融入左侧视图列表
-
编辑、删除入口融入右侧当前视图详情
-
左侧全部视图列表
-
不要求先选择对象
-
支持按视图名称、说明或绑定对象搜索
-
纵向滚动浏览、快速切换
-
每个视图展示列数、筛选字段数、图表数等摘要
-
新建入口融入左侧分组树和右侧视图列表区
-
编辑、删除入口融入右侧视图列表的行操作区
-
左侧分组树
-
支持无限级分组
-
菜单只显示分组名
-
支持新建、重命名、删除空分组
-
右侧视图列表区
-
展示当前分组及其所有子分组下的全部视图
-
支持按视图名称、说明、绑定对象和标签搜索
-
每行视图展示说明、对象、分组、标签、数据量、列数、筛选字段数、图表数和行操作
-
点击视图行切换到该视图,当前视图行需要有清晰选中态
-
右侧当前视图工作区
-
根据当前视图自动加载绑定对象
-
展示动态筛选区
...
...
@@ -221,13 +325,19 @@
-
结果区 Tab
-
`表格`
Tab:结果表格与勾选工具条
-
表格固定高度,超出后在表格区域内部滚动
-
表格每条记录展示当前下发/审批状态
-
表格标题列作为行级回填详情入口
-
行级回填详情弹窗展示是否属实、整改方案和预计整改完成时间
-
`图表`
Tab:当前视图配置的图表卡片
-
支持结果区全屏 / 缩小
-
当前页勾选工具条
-
转发审批弹窗
-
支持区分“本次下发”与“本次不下发”的记录
-
提交后为本次下发记录初始化行级回填明细
-
视图配置弹窗
-
绑定对象同样使用下拉搜索框
-
不展示个人视图 / 共享视图区分
-
支持选择所属分组和维护标签
-
图表配置卡片支持逐项删除
### 6.3 转发审批
...
...
@@ -239,7 +349,6 @@
-
审批详情区
-
当前状态展示
-
通过 / 驳回按钮
-
手工补录弹窗
### 6.4 图表看板
...
...
@@ -274,7 +383,17 @@
-
更新视图:
-
`PUT /record/:name`
### 7.3 转发审批持久化
### 7.3 视图分组持久化
-
创建分组:
-
`POST /record`
-
`subject = analysis_view_group`
-
更新分组:
-
`PUT /record/:name`
-
删除分组:
-
`DELETE /record/:name`
### 7.4 转发审批持久化
-
创建转发记录:
-
`POST /record`
...
...
@@ -282,7 +401,7 @@
-
更新审批状态:
-
`PUT /record/:name`
### 7.
4
图表看板持久化
### 7.
5
图表看板持久化
-
创建看板:
-
`POST /record`
...
...
@@ -290,24 +409,49 @@
-
更新看板:
-
`PUT /record/:name`
### 7.
5
用户选择
### 7.
6
用户选择
-
查询系统用户:
-
`POST /user/query`
-
转发审批弹窗中的接收人、一级审批人、终审人都必须在输入关键字时远程调用该接口,不允许只做本地静态筛选
-
推荐请求体:
-
`page = 0`
-
`page_size = 20`
-
`filter.$or = [{ username: { $regex: keyword } }, { display_name: { $regex: keyword } }]`
-
`status = null`
-
`groups = false`
-
接收人、一级审批人、终审人优先复用该接口
-
查询绑定对象:
-
`POST /subject/query`
-
视图配置弹窗中的绑定对象必须在输入关键字时远程调用该接口,不允许只做本地静态筛选
-
推荐请求体:
-
`page = 0`
-
`page_size = 10`
-
`keyword = 搜索词`
-
`sort = "title name"`
-
推荐返回结构兼容:
-
`subjects = []`
-
`total_subjects = number`
---
## 8. 异常处理与限制条件
-
若对象定义不存在,页面必须优雅降级,不得白屏
-
若
`analysis_view_config`
或
`analysis_forward_record`
这两个分析专用 subject 在当前环境不存在,前端需回退展示内置演示数据:
-
若
`analysis_view_config`
、
`analysis_view_group`
或
`analysis_forward_record`
这些分析专用 subject 在当前环境不存在,前端需回退展示内置演示数据:
-
默认展示 4 条分析视图
-
默认展示若干分组节点
-
默认展示 5 条转发审批记录
-
仅当真实接口失败且本地缓存为空时启用,避免覆盖用户本地已创建的数据
-
对象选择区域不得一次性加载全部对象,默认只展示 10 条并通过搜索补充
-
视图数量较多时必须保留
纵向滚动列表,避免页面被视图卡片
无限拉长
-
视图数量较多时必须保留
分组树和视图列表区的独立滚动,避免页面被
无限拉长
-
若分析视图保存失败,需要明确提示失败原因
-
若图表配置不完整,视图仍可保存,但未完成的图表卡片应显示为空状态
-
若转发提交失败,不得清空当前勾选记录
-
若一级审批未通过,终审按钮必须不可用
-
审批状态与最终审批状态必须始终分别维护,不能共用一个字段
-
当前批次未结束前,
`excluded_record_ids`
命中的记录在原视图表格中需隐藏
-
图表看板至少需要 1 张卡片才能保存
-
`select`
类型选项必须来自
`settings.options`
,严禁前端私自扩展业务枚举
-
`isPrimaryKey: true`
的字段在编辑态必须禁用
...
...
src/pages/project_manager_xy/projectTaskService.js
View file @
5a3fb30d
...
...
@@ -317,7 +317,7 @@ export async function searchSystemUsers(keyword = '') {
:
''
,
null
,
);
return
mergeUserRecords
(
response
?.
records
||
[]);
return
mergeUserRecords
(
response
?.
users
||
response
?.
records
||
[]);
}
export
async
function
fetchSystemUsersByIds
(
ids
=
[])
{
...
...
src/services/DataService.js
View file @
5a3fb30d
...
...
@@ -603,7 +603,7 @@ function parseDocument(formData) {
}
// user
function
queryUsers
(
page
,
page_size
,
filter
=
{},
status
=
null
)
{
function
queryUsers
(
page
,
page_size
,
filter
=
{},
status
=
null
,
groups
=
false
)
{
return
request
(
'/user/query'
,
{
method
:
'post'
,
data
:
{
...
...
@@ -611,6 +611,7 @@ function queryUsers(page, page_size, filter = {}, status = null) {
page_size
,
filter
,
status
,
groups
,
},
});
}
...
...
src/services/subjectsDefinition.md
View file @
5a3fb30d
...
...
@@ -8,7 +8,9 @@
-
`am_material`
:审计材料主档
-
`analysis_view_config`
:分析视图配置
-
`analysis_view_group`
:分析视图分组
-
`analysis_forward_record`
:转发审批记录
-
`analysis_forward_record_item`
:转发记录行级回填明细
-
`analysis_dashboard_board`
:图表看板配置
---
...
...
@@ -140,20 +142,24 @@ type Record = {
-
**subject**
:
`analysis_view_config`
一条记录代表一个“单对象分析视图”,保存对象、筛选、结果列
与图表配置
。
一条记录代表一个“单对象分析视图”,保存对象、筛选、结果列
、图表配置、所属分组与标签
。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 视图名称 |
`view_name`
| text | 否 | 否 | 是 | 业务展示名称 |
| 数据源对象 |
`subject_name`
|
subjec
t | 否 | 否 | 是 | 绑定的单个对象名 |
| 数据源对象 |
`subject_name`
|
tex
t | 否 | 否 | 是 | 绑定的单个对象名 |
| 视图说明 |
`description`
| text | 否 | 否 | 否 | 给业务看的说明 |
| 兼容范围 |
`scope`
|
selec
t | 否 | 否 | 否 | 仅作为历史兼容字段,前端不再区分个人视图/共享视图 |
| 筛选字段 |
`filter_fields`
|
json
| 否 | 是 | 否 | 存字段别名数组 |
| 默认筛选值 |
`default_filters`
|
json
| 否 | 否 | 否 | 视图默认筛选配置 |
| 结果列 |
`columns`
|
json
| 否 | 是 | 是 | 存字段别名数组 |
| 图表配置 |
`charts`
|
json
| 否 | 是 | 否 | 1-N 个图表卡片配置 |
| 创建人 ID |
`created_by_user_id`
|
user
| 否 | 否 | 否 | 保存视图创建人 |
| 兼容范围 |
`scope`
|
tex
t | 否 | 否 | 否 | 仅作为历史兼容字段,前端不再区分个人视图/共享视图 |
| 筛选字段 |
`filter_fields`
|
text
| 否 | 是 | 否 | 存字段别名数组 |
| 默认筛选值 |
`default_filters`
|
object
| 否 | 否 | 否 | 视图默认筛选配置 |
| 结果列 |
`columns`
|
text
| 否 | 是 | 是 | 存字段别名数组 |
| 图表配置 |
`charts`
|
object
| 否 | 是 | 否 | 1-N 个图表卡片配置 |
| 创建人 ID |
`created_by_user_id`
|
text
| 否 | 否 | 否 | 保存视图创建人 |
| 创建人名称 |
`created_by_user_name`
| text | 否 | 否 | 否 | 便于展示 |
| 所属分组 ID |
`group_id`
| text | 否 | 否 | 否 | 关联
`analysis_view_group`
的记录 ID |
| 分组名 |
`group_name`
| text | 否 | 否 | 否 | 冗余展示用 |
| 视图标签 |
`tags`
| text | 否 | 是 | 否 | 支持多标签检索和展示 |
| 手动隐藏记录 ID 列表 |
`hidden_record_ids`
| text | 否 | 是 | 否 | 当前视图中被业务人员手动隐藏的记录 ID |
### 3.2.1 `scope` 兼容约束
...
...
@@ -188,37 +194,102 @@ type Record = {
]
```
## 3.3 转发审批记录
## 3.3 分析视图分组
-
**subject**
:
`analysis_view_group`
一条记录代表一个分析视图分组节点,用于左侧无限级树形菜单。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 分组名称 |
`group_name`
| text | 否 | 否 | 是 | 文件夹名称 |
| 父分组 ID |
`parent_group_id`
| text | 否 | 否 | 否 | 上级分组记录 ID,根分组可为空 |
| 创建人 ID |
`created_by_user_id`
| user | 否 | 否 | 否 | 创建人 |
| 创建人名称 |
`created_by_user_name`
| text | 否 | 否 | 否 | 展示用途 |
## 3.4 转发审批记录
-
**subject**
:
`analysis_forward_record`
一条记录代表一次“从分析视图结果区勾选记录后发起的转发审批”。
一条记录代表一次“从分析视图结果区勾选记录后发起的转发审批
批次
”。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 来源视图 ID |
`view_id`
|
reference
| 否 | 否 | 是 | 仅存视图记录 ID |
| 来源视图 ID |
`view_id`
|
text
| 否 | 否 | 是 | 仅存视图记录 ID |
| 来源视图名称 |
`view_name`
| text | 否 | 否 | 否 | 展示用途 |
| 数据源对象 |
`subject_name`
| subject | 否 | 否 | 是 | 该次转发对应哪个对象 |
| 勾选记录 ID 列表 |
`selected_record_ids`
| json | 否 | 是 | 是 | 仅保存 record
`name/_id`
数组 |
| 接收人 ID 列表 |
`recipient_ids`
| user | 否 | 是 | 否 | 仅保存用户 ID 数组 |
| 接收人名称列表 |
`recipient_names`
| json | 否 | 是 | 否 | 展示用途 |
| 数据源对象 |
`subject_name`
| text | 否 | 否 | 是 | 该次转发对应哪个对象 |
| 勾选记录 ID 列表 |
`selected_record_ids`
| text | 否 | 是 | 是 | 仅保存 record
`name/_id`
数组 |
| 接收人 ID 列表 |
`recipient_ids`
| text | 否 | 是 | 否 | 仅保存用户 ID 数组 |
| 接收人名称列表 |
`recipient_names`
| text | 否 | 是 | 否 | 展示用途 |
| 不下发记录 ID 列表 |
`excluded_record_ids`
| text | 否 | 是 | 否 | 本批次中选中但明确不下发的记录 ID |
| 转发说明 |
`note`
| text | 否 | 否 | 是 | 补充转发记录数据 |
| 审批状态 |
`approval_status`
| select | 否 | 否 | 是 | 一级审批状态 |
| 最终审批状态 |
`final_approval_status`
| select | 否 | 否 | 是 | 终审状态 |
| 一级审批人 |
`first_approver`
| user | 否 | 否 | 是 | 一级审批人 ID |
| 填写状态 |
`editor_status`
| select | 否 | 否 | 是 | 接收人当前是否仍可填写 |
| 完成人 ID |
`completed_by_id`
| text | 否 | 否 | 否 | 任一接收人点击完成后的用户 ID |
| 完成人名称 |
`completed_by_name`
| text | 否 | 否 | 否 | 展示用途 |
| 完成时间 |
`completed_at`
| date | 否 | 否 | 否 | 点击完成填写的时间 |
| 审批状态 |
`approval_status`
| text | 否 | 否 | 是 | 一级审批状态 |
| 最终审批状态 |
`final_approval_status`
| text | 否 | 否 | 是 | 终审状态 |
| 一级审批人 |
`first_approver`
| text | 否 | 否 | 是 | 一级审批人 ID |
| 一级审批人名称 |
`first_approver_name`
| text | 否 | 否 | 否 | 展示用途 |
| 终审人 |
`final_approver`
|
user
| 否 | 否 | 是 | 终审人 ID |
| 终审人 |
`final_approver`
|
text
| 否 | 否 | 是 | 终审人 ID |
| 终审人名称 |
`final_approver_name`
| text | 否 | 否 | 否 | 展示用途 |
| 发起人 ID |
`initiator_id`
|
user
| 否 | 否 | 否 | 当前登录用户 |
| 发起人 ID |
`initiator_id`
|
text
| 否 | 否 | 否 | 当前登录用户 |
| 发起人名称 |
`initiator_name`
| text | 否 | 否 | 否 | 展示用途 |
### 3.
3
.1 审批状态约束
### 3.
4
.1 审批状态约束
-
`editor_status`
推荐值:
`editing`
/
`completed`
-
`approval_status`
与
`final_approval_status`
为两个独立字段
-
推荐值:
`pending`
/
`approved`
/
`rejected`
-
接收人收到批次后,只要
`editor_status = editing`
,即视为“填写中”,仍可填写或保存行级回填明细
-
任一接收人点击完成后,需同步写入:
-
`editor_status = completed`
-
`completed_by_id`
-
`completed_by_name`
-
`completed_at`
-
当
`editor_status = completed`
后,该批次对应行级回填明细需锁定为不可再编辑,并进入真正的待审批阶段
-
终审只能在
`approval_status = approved`
后推进
-
表格逐行流程状态由前端根据当前视图下最新命中的
`analysis_forward_record`
批次映射:
-
未命中任何批次:
`待发送`
-
命中批次且
`editor_status = editing`
:
`进行中`
-
命中批次且
`editor_status = completed`
、
`approval_status = pending`
:
`待审批`
-
命中批次且
`approval_status = approved`
、
`final_approval_status = pending`
:
`已审批`
-
命中批次且
`final_approval_status = approved`
:
`已最终审批`
-
任一审批字段为
`rejected`
:
`已驳回`
-
`excluded_record_ids`
仅用于当前未结束批次内的表格隐藏,不回写视图配置对象
## 3.5 转发记录行级回填明细
-
**subject**
:
`analysis_forward_record_item`
一条记录代表某个转发批次里某一条源记录的回填结果,用于保存“是否属实、整改方案、预计整改完成时间”,不回写源对象字段。
## 3.4 图表看板配置
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 来源转发批次 ID |
`forward_record_id`
| text | 否 | 否 | 是 | 关联
`analysis_forward_record`
记录 ID |
| 来源转发批次名称 |
`forward_record_title`
| text | 否 | 否 | 否 | 展示用途 |
| 来源视图 ID |
`view_id`
| text | 否 | 否 | 是 | 关联分析视图 |
| 来源视图名称 |
`view_name`
| text | 否 | 否 | 否 | 展示用途 |
| 数据源对象 |
`subject_name`
| text | 否 | 否 | 是 | 如
`am_material`
|
| 源记录 ID |
`source_record_id`
| text | 否 | 否 | 是 | 被下发的原始记录 ID |
| 源记录标题 |
`source_record_title`
| text | 否 | 否 | 否 | 展示用途 |
| 是否属实 |
`truth_status`
| select | 否 | 否 | 是 | 选项来自
`settings.options`
,推荐
`truthful/untruthful`
|
| 整改方案 |
`rectification_plan`
| long_text | 否 | 否 | 是 | 接收人填写 |
| 预计整改完成时间 |
`expected_rectification_completed_at`
| date | 否 | 否 | 是 | ISO 8601 字符串 |
| 填写人 ID |
`filled_by_id`
| user | 否 | 否 | 否 | 最近保存人 |
| 填写人名称 |
`filled_by_name`
| text | 否 | 否 | 否 | 展示用途 |
| 填写时间 |
`filled_at`
| date | 否 | 否 | 否 | 最近保存时间 |
| 备注 |
`remark`
| long_text | 否 | 否 | 否 | 后续扩展 |
### 3.2.3 视图隐藏记录约束
-
`hidden_record_ids`
归属于
`analysis_view_config`
-
业务人员在某个视图中手动隐藏记录后,需要把对应记录 ID 持久化到该字段
-
用户再次进入同一个视图时,命中
`hidden_record_ids`
的记录默认不展示
-
需要提供单独的“已隐藏记录管理 / 恢复显示”界面
-
该字段与转发审批无关,转发出去的记录不会因为转发动作自动从视图中消失
## 3.6 图表看板配置
-
**subject**
:
`analysis_dashboard_board`
...
...
@@ -233,7 +304,7 @@ type Record = {
| 创建人 ID |
`created_by_user_id`
| user | 否 | 否 | 否 | 创建人 |
| 创建人名称 |
`created_by_user_name`
| text | 否 | 否 | 否 | 展示用途 |
### 3.
4
.1 `widgets` 配置示例
### 3.
6
.1 `widgets` 配置示例
```
json
[
...
...
@@ -267,5 +338,6 @@ type Record = {
-
第一期开启“任意对象可分析”,但单个保存视图只绑定一个对象
-
转发基于“视图下勾选记录”发起,不做“整视图直接分发”
-
每条下发记录的属实情况与整改信息写入
`analysis_forward_record_item`
,不写回源对象字段
-
配置持久化全部采用独立 Record 对象,继续走统一
`metadata`
协议
-
若后端未正式创建
`analysis_view_config`
、
`analysis_forward_record`
、
`analysis_dashboard_board`
,前端需优雅降级,不得因为对象未建而白屏
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