Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
P
problem_rectification
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
problem_rectification
Commits
e2c3190a
Commit
e2c3190a
authored
Oct 07, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: improve archive details and cache rectification counts
parent
9bf42156
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
571 additions
and
47 deletions
+571
-47
ReferenceRecordsTable.jsx
...s/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
+6
-7
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+10
-2
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+18
-22
ArchiveSnapshotItems.jsx
...es/problem_rectification/archive/ArchiveSnapshotItems.jsx
+166
-0
ProblemRectificationArchiveRecordsTable.jsx
...ation/archive/ProblemRectificationArchiveRecordsTable.jsx
+22
-0
archiveSnapshotItems.mjs
...es/problem_rectification/archive/archiveSnapshotItems.mjs
+37
-0
archiveWorkbenchService.js
.../problem_rectification/archive/archiveWorkbenchService.js
+12
-0
index.jsx
src/pages/problem_rectification/list/index.jsx
+30
-5
problem-rectification.business-flow.md
...blem_rectification/problem-rectification.business-flow.md
+13
-3
problemRectificationListService.js
.../problem_rectification/problemRectificationListService.js
+34
-8
record-api.md
src/services/record-api.md
+14
-0
archiveSnapshotItems.test.mjs
tests/archiveSnapshotItems.test.mjs
+37
-0
problemRectificationListRequests.test.mjs
tests/problemRectificationListRequests.test.mjs
+172
-0
No files found.
src/pages/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
View file @
e2c3190a
...
@@ -121,7 +121,7 @@ function ReferenceRecordsFrame({ action, count, children }) {
...
@@ -121,7 +121,7 @@ function ReferenceRecordsFrame({ action, count, children }) {
);
);
}
}
export
default
function
ReferenceRecordsTable
({
fieldDef
,
value
,
pageSize
=
1000
,
action
=
null
})
{
export
default
function
ReferenceRecordsTable
({
fieldDef
,
value
,
pageSize
=
1000
,
action
=
null
,
renderCell
})
{
const
subjectName
=
getReferenceSubjectName
(
fieldDef
);
const
subjectName
=
getReferenceSubjectName
(
fieldDef
);
const
referenceIds
=
useMemo
(()
=>
getReferenceIds
(
value
),
[
value
]);
const
referenceIds
=
useMemo
(()
=>
getReferenceIds
(
value
),
[
value
]);
const
populatedRecords
=
useMemo
(
const
populatedRecords
=
useMemo
(
...
@@ -249,12 +249,11 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
...
@@ -249,12 +249,11 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
title
:
column
.
label
,
title
:
column
.
label
,
minWidth
:
index
===
0
?
240
:
180
,
minWidth
:
index
===
0
?
240
:
180
,
isPrimary
:
index
===
0
,
isPrimary
:
index
===
0
,
render
:
(
record
)
=>
(
render
:
(
record
)
=>
{
<
CellValue
const
value
=
getFieldValue
(
record
,
column
.
field
);
value=
{
getFieldValue
(
record
,
column
.
field
)
}
const
customCell
=
renderCell
?.({
column
,
record
,
value
});
field=
{
column
}
return
customCell
!==
undefined
?
customCell
:
<
CellValue
value=
{
value
}
field=
{
column
}
/>;
/>
},
),
}))
}
}))
}
rows=
{
records
}
rows=
{
records
}
getRowKey=
{
(
record
,
rowIndex
)
=>
getRowKey=
{
(
record
,
rowIndex
)
=>
...
...
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
e2c3190a
...
@@ -17,6 +17,7 @@ import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
...
@@ -17,6 +17,7 @@ import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
ProblemRectificationMaterialField
from
'@/pages/problem_rectification/components/ProblemRectificationMaterialField'
;
import
ProblemRectificationMaterialField
from
'@/pages/problem_rectification/components/ProblemRectificationMaterialField'
;
import
ReferenceRecordsTable
from
'./ReferenceRecordsTable'
;
import
ReferenceRecordsTable
from
'./ReferenceRecordsTable'
;
import
ProblemRectificationArchiveRecordsTable
from
'@/pages/problem_rectification/archive/ProblemRectificationArchiveRecordsTable'
;
import
{
import
{
dateInputValue
,
dateInputValue
,
formatFieldValue
,
formatFieldValue
,
...
@@ -292,9 +293,16 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
...
@@ -292,9 +293,16 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
);
);
}
}
function
ReadonlyReferenceRecordsTable
(
props
)
{
const
Table
=
getReferenceSubjectName
(
props
.
fieldDef
)
===
'problem_rectification_archive_records'
?
ProblemRectificationArchiveRecordsTable
:
ReferenceRecordsTable
;
return
<
Table
{
...
props
}
/>;
}
function
ReadonlyFieldValue
({
value
,
fieldDef
})
{
function
ReadonlyFieldValue
({
value
,
fieldDef
})
{
if
(
isReferenceFieldType
(
fieldDef
?.
type
))
{
if
(
isReferenceFieldType
(
fieldDef
?.
type
))
{
return
<
ReferenceRecordsTable
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
return
<
Re
adonlyRe
ferenceRecordsTable
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
}
}
return
<
FieldValue
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
return
<
FieldValue
value=
{
value
}
fieldDef=
{
fieldDef
}
/>;
...
@@ -670,7 +678,7 @@ export default function TailwindEditableField({
...
@@ -670,7 +678,7 @@ export default function TailwindEditableField({
if
(
isReferenceFieldType
(
fieldType
))
{
if
(
isReferenceFieldType
(
fieldType
))
{
return
(
return
(
<
div
>
<
div
>
<
ReferenceRecordsTable
<
Re
adonlyRe
ferenceRecordsTable
value=
{
value
}
value=
{
value
}
fieldDef=
{
fieldDef
}
fieldDef=
{
fieldDef
}
action=
{
action=
{
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
e2c3190a
import
FullscreenExitOutlined
from
'@ant-design/icons/FullscreenExitOutlined'
;
import
{
Close
,
Fullscreen
,
FullscreenExit
}
from
'@/components/AppIcons'
;
import
FullscreenOutlined
from
'@ant-design/icons/FullscreenOutlined'
;
import
{
Close
}
from
'@/components/AppIcons'
;
import
{
Alert
,
Button
,
Modal
,
Spinner
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
{
Alert
,
Button
,
Modal
,
Spinner
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
React
,
{
import
React
,
{
useCallback
,
useCallback
,
...
@@ -372,25 +370,23 @@ const RecordDetailDrawer = (props, ref) => {
...
@@ -372,25 +370,23 @@ const RecordDetailDrawer = (props, ref) => {
<
Modal
.
Container
placement=
"center"
scroll=
"inside"
size=
{
isFullscreen
?
'full'
:
'lg'
}
>
<
Modal
.
Container
placement=
"center"
scroll=
"inside"
size=
{
isFullscreen
?
'full'
:
'lg'
}
>
<
Modal
.
Dialog
className=
{
isFullscreen
?
undefined
:
'p-7 sm:max-w-[68rem]'
}
>
<
Modal
.
Dialog
className=
{
isFullscreen
?
undefined
:
'p-7 sm:max-w-[68rem]'
}
>
<
div
className=
"absolute right-7 top-7 z-10 flex items-center gap-2"
>
<
div
className=
"absolute right-7 top-7 z-10 flex items-center gap-2"
>
{
!
minimalContent
?
(
<
Tooltip
>
<
Tooltip
>
<
Button
<
Button
aria
-
label=
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
aria
-
label=
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
className=
"size-6 min-w-6 p-0"
className=
"size-6 min-w-6 p-0"
isIconOnly
isIconOnly
size=
"sm"
size=
"sm"
variant=
"ghost"
variant=
"ghost"
onPress=
{
()
=>
setIsFullscreen
((
current
)
=>
!
current
)
}
onPress=
{
()
=>
setIsFullscreen
((
current
)
=>
!
current
)
}
>
>
{
isFullscreen
?
(
{
isFullscreen
?
(
<
FullscreenExit
size=
{
16
}
/>
<
FullscreenExitOutlined
className=
"block"
aria
-
hidden=
"true"
/>
)
:
(
)
:
(
<
Fullscreen
size=
{
16
}
/>
<
FullscreenOutlined
className=
"block"
aria
-
hidden=
"true"
/>
)
}
)
}
</
Button
>
</
Button
>
<
Tooltip
.
Content
>
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
</
Tooltip
.
Content
>
<
Tooltip
.
Content
>
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
</
Tooltip
.
Content
>
</
Tooltip
>
</
Tooltip
>
)
:
null
}
{
!
isEditing
?
(
{
!
isEditing
?
(
<
Modal
.
CloseTrigger
className=
"static"
aria
-
label=
"关闭"
>
<
Modal
.
CloseTrigger
className=
"static"
aria
-
label=
"关闭"
>
<
Close
size=
{
16
}
/>
<
Close
size=
{
16
}
/>
...
...
src/pages/problem_rectification/archive/ArchiveSnapshotItems.jsx
0 → 100644
View file @
e2c3190a
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Alert
,
Button
,
Link
,
Modal
,
Spinner
,
Surface
,
Tooltip
}
from
'@heroui/react'
;
import
{
Close
,
Eye
,
Fullscreen
,
FullscreenExit
}
from
'@/components/AppIcons'
;
import
{
formatFieldValue
,
toArray
}
from
'@/utils/subjectView'
;
import
{
loadArchiveSnapshotItemSubject
}
from
'./archiveWorkbenchService'
;
import
{
ARCHIVE_SNAPSHOT_ITEM_FIELDS
,
normalizeArchiveSnapshotItems
}
from
'./archiveSnapshotItems.mjs'
;
const
FULL_WIDTH_FIELD_TYPES
=
new
Set
([
'long_text'
,
'rich_text'
,
'file'
,
'object'
,
'subject'
]);
function
SnapshotFieldValue
({
field
,
value
})
{
if
(
field
.
type
===
'file'
&&
toArray
(
value
).
length
>
0
)
{
return
(
<
div
className=
"flex flex-wrap gap-2"
>
{
toArray
(
value
).
map
((
file
,
index
)
=>
{
const
label
=
file
?.
name
||
file
?.
title
||
`附件 ${index + 1}`
;
return
file
?.
url
?
(
<
Link
key=
{
`${file.url}-${index}`
}
href=
{
file
.
url
}
target=
"_blank"
rel=
"noreferrer"
>
{
label
}
</
Link
>
)
:
<
span
key=
{
index
}
>
{
label
}
</
span
>;
})
}
</
div
>
);
}
const
text
=
formatFieldValue
(
value
,
field
);
return
(
<
span
className=
{
`whitespace-pre-wrap break-words text-sm ${text ? 'text-foreground' : 'text-muted'}`
}
>
{
text
||
'暂无内容'
}
</
span
>
);
}
export
default
function
ArchiveSnapshotItems
({
fieldName
,
value
,
recordTitle
})
{
const
configuration
=
ARCHIVE_SNAPSHOT_ITEM_FIELDS
[
fieldName
];
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
[
isFullscreen
,
setIsFullscreen
]
=
useState
(
false
);
const
[
subject
,
setSubject
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
retryKey
,
setRetryKey
]
=
useState
(
0
);
const
snapshot
=
useMemo
(()
=>
{
try
{
return
{
items
:
normalizeArchiveSnapshotItems
(
value
),
error
:
''
};
}
catch
(
parseError
)
{
return
{
items
:
[],
error
:
parseError
.
message
};
}
},
[
value
]);
useEffect
(()
=>
{
if
(
!
isOpen
||
snapshot
.
error
||
snapshot
.
items
.
length
===
0
)
return
;
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
setSubject
(
null
);
loadArchiveSnapshotItemSubject
(
fieldName
)
.
then
((
definition
)
=>
{
if
(
!
cancelled
)
setSubject
(
definition
);
})
.
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
setError
(
requestError
?.
message
||
'归档明细字段加载失败,请稍后重试。'
);
})
.
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
fieldName
,
isOpen
,
retryKey
,
snapshot
]);
if
(
!
snapshot
.
error
&&
snapshot
.
items
.
length
===
0
)
{
return
<
span
className=
"text-sm text-muted"
>
暂无
{
configuration
.
label
}
</
span
>;
}
return
(
<
Modal
.
Root
isOpen=
{
isOpen
}
onOpenChange=
{
(
nextOpen
)
=>
{
setIsOpen
(
nextOpen
);
if
(
nextOpen
)
setIsFullscreen
(
false
);
}
}
>
<
Modal
.
Trigger
render=
{
(
triggerProps
)
=>
(
<
Button
{
...
triggerProps
}
size=
"sm"
variant=
"secondary"
aria
-
label=
{
`查看${recordTitle || '归档问题'}的${configuration.label}`
}
/>
)
}
>
<
Eye
size=
{
16
}
/>
{
snapshot
.
error
?
'查看异常数据'
:
`${snapshot.items.length} 条明细`
}
</
Modal
.
Trigger
>
<
Modal
.
Backdrop
variant=
"blur"
>
<
Modal
.
Container
placement=
"center"
scroll=
"inside"
size=
{
isFullscreen
?
'full'
:
'lg'
}
>
<
Modal
.
Dialog
className=
{
isFullscreen
?
undefined
:
'p-7 sm:max-w-[68rem]'
}
>
<
Modal
.
Header
className=
"flex-row items-start justify-between gap-4"
>
<
div
className=
"min-w-0 space-y-1"
>
<
Modal
.
Heading
>
{
configuration
.
label
}
</
Modal
.
Heading
>
<
p
className=
"break-words text-sm text-muted"
>
{
recordTitle
||
'归档问题'
}
· 历史快照
</
p
>
</
div
>
<
div
className=
"flex shrink-0 items-center gap-2"
>
<
Tooltip
>
<
Button
aria
-
label=
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
className=
"size-6 min-w-6 p-0"
isIconOnly
size=
"sm"
variant=
"ghost"
onPress=
{
()
=>
setIsFullscreen
((
current
)
=>
!
current
)
}
>
{
isFullscreen
?
<
FullscreenExit
size=
{
16
}
/>
:
<
Fullscreen
size=
{
16
}
/>
}
</
Button
>
<
Tooltip
.
Content
>
{
isFullscreen
?
'退出全屏'
:
'全屏查看'
}
</
Tooltip
.
Content
>
</
Tooltip
>
<
Modal
.
CloseTrigger
className=
"static"
aria
-
label=
"关闭明细"
>
<
Close
size=
{
16
}
/>
</
Modal
.
CloseTrigger
>
</
div
>
</
Modal
.
Header
>
<
Modal
.
Body
>
{
snapshot
.
error
||
error
?
(
<
div
className=
"space-y-3"
>
<
Alert
status=
"danger"
>
<
Alert
.
Content
><
Alert
.
Description
>
{
snapshot
.
error
||
error
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
{
!
snapshot
.
error
?
(
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setRetryKey
((
current
)
=>
current
+
1
)
}
>
重试
</
Button
>
)
:
null
}
</
div
>
)
:
loading
||
!
subject
?
(
<
div
className=
"flex min-h-32 items-center justify-center"
role=
"status"
aria
-
label=
"正在加载归档明细字段"
>
<
Spinner
size=
"sm"
/>
</
div
>
)
:
(
<
div
className=
"space-y-6"
>
{
snapshot
.
items
.
map
((
item
,
index
)
=>
(
<
section
key=
{
`${item.item_id || index}-${index}`
}
className=
"min-w-0 space-y-3"
>
<
h3
className=
"text-sm font-medium"
>
{
item
.
responsible_department
||
`第 ${index + 1} 条明细`
}
</
h3
>
<
Surface
variant=
"secondary"
className=
"rounded-2xl px-5 py-2 sm:px-6 sm:py-3"
>
<
dl
className=
"grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"
>
{
subject
.
fields
.
map
((
field
)
=>
(
<
div
key=
{
field
.
name
}
className=
{
`min-w-0 py-3 ${FULL_WIDTH_FIELD_TYPES.has(field.type) ? 'md:col-span-2' : ''}`
}
>
<
dt
className=
"text-sm font-medium text-muted"
>
{
field
.
label
||
field
.
name
}
</
dt
>
<
dd
className=
"mt-1.5 min-w-0"
><
SnapshotFieldValue
field=
{
field
}
value=
{
item
[
field
.
name
]
}
/></
dd
>
</
div
>
))
}
</
dl
>
</
Surface
>
</
section
>
))
}
</
div
>
)
}
</
Modal
.
Body
>
</
Modal
.
Dialog
>
</
Modal
.
Container
>
</
Modal
.
Backdrop
>
</
Modal
.
Root
>
);
}
src/pages/problem_rectification/archive/ProblemRectificationArchiveRecordsTable.jsx
0 → 100644
View file @
e2c3190a
import
ReferenceRecordsTable
from
'@/pages/RecordDetailDrawer/components/ReferenceRecordsTable'
;
import
ArchiveSnapshotItems
from
'./ArchiveSnapshotItems'
;
import
{
ARCHIVE_SNAPSHOT_ITEM_FIELDS
}
from
'./archiveSnapshotItems.mjs'
;
export
default
function
ProblemRectificationArchiveRecordsTable
(
props
)
{
return
(
<
ReferenceRecordsTable
{
...
props
}
renderCell=
{
({
column
,
record
,
value
})
=>
{
const
fieldName
=
column
.
field
.
replace
(
/^metadata
\.
/
,
''
);
if
(
!
ARCHIVE_SNAPSHOT_ITEM_FIELDS
[
fieldName
])
return
undefined
;
return
(
<
ArchiveSnapshotItems
fieldName=
{
fieldName
}
value=
{
value
}
recordTitle=
{
record
.
title
||
record
.
metadata
?.
problem_brief
||
record
.
name
}
/>
);
}
}
/>
);
}
src/pages/problem_rectification/archive/archiveSnapshotItems.mjs
0 → 100644
View file @
e2c3190a
export
const
ARCHIVE_SNAPSHOT_ITEM_FIELDS
=
Object
.
freeze
({
department_rectification_items
:
{
subjectName
:
'department_rectification_items'
,
label
:
'部门整改明细'
,
},
problem_rectification_delay_request_items
:
{
subjectName
:
'problem_rectification_delay_request'
,
label
:
'问题整改延期'
,
},
});
function
parseSnapshotValue
(
value
)
{
if
(
typeof
value
!==
'string'
)
return
value
;
if
(
!
value
.
trim
())
return
null
;
try
{
return
JSON
.
parse
(
value
);
}
catch
{
throw
new
Error
(
'归档明细的 JSON 格式不正确,无法展示,请联系管理员检查历史快照。'
);
}
}
export
function
normalizeArchiveSnapshotItems
(
value
)
{
const
parsedValue
=
parseSnapshotValue
(
value
);
if
(
parsedValue
===
null
||
parsedValue
===
undefined
)
return
[];
return
(
Array
.
isArray
(
parsedValue
)
?
parsedValue
:
[
parsedValue
]).
map
((
value
)
=>
{
const
item
=
parseSnapshotValue
(
value
);
if
(
!
item
||
typeof
item
!==
'object'
||
Array
.
isArray
(
item
))
{
throw
new
Error
(
'归档明细包含无法识别的数据,无法展示,请联系管理员检查历史快照。'
);
}
const
{
metadata
,
...
recordFields
}
=
item
;
return
{
...
recordFields
,
...(
metadata
&&
typeof
metadata
===
'object'
&&
!
Array
.
isArray
(
metadata
)
?
metadata
:
{}),
};
});
}
src/pages/problem_rectification/archive/archiveWorkbenchService.js
View file @
e2c3190a
...
@@ -10,10 +10,22 @@ import {
...
@@ -10,10 +10,22 @@ import {
recordMatchesRestrictions
,
recordMatchesRestrictions
,
sortRecordsByView
,
sortRecordsByView
,
}
from
'@/utils/subjectView'
;
}
from
'@/utils/subjectView'
;
import
{
ARCHIVE_SNAPSHOT_ITEM_FIELDS
}
from
'./archiveSnapshotItems.mjs'
;
export
const
ISSUE_RECTIFICATION_ARCHIVE_SUBJECT
=
'issue_rectification_archive'
;
export
const
ISSUE_RECTIFICATION_ARCHIVE_SUBJECT
=
'issue_rectification_archive'
;
export
const
PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT
=
'problem_rectification_archive_records'
;
export
const
PROBLEM_RECTIFICATION_ARCHIVE_RECORDS_SUBJECT
=
'problem_rectification_archive_records'
;
export
async
function
loadArchiveSnapshotItemSubject
(
fieldName
)
{
const
configuration
=
ARCHIVE_SNAPSHOT_ITEM_FIELDS
[
fieldName
];
if
(
!
configuration
)
throw
new
Error
(
'归档明细缺少对象配置。'
);
const
subject
=
await
loadSubject
(
configuration
.
subjectName
);
if
(
!
Array
.
isArray
(
subject
?.
fields
)
||
subject
.
fields
.
length
===
0
)
{
throw
new
Error
(
'归档明细字段定义为空,请联系管理员检查对象配置。'
);
}
return
subject
;
}
export
function
normalizeArchiveBatchRecord
(
record
)
{
export
function
normalizeArchiveBatchRecord
(
record
)
{
const
referenceRecords
=
Array
.
isArray
(
record
?.
metadata
?.
reference_records
)
const
referenceRecords
=
Array
.
isArray
(
record
?.
metadata
?.
reference_records
)
?
record
.
metadata
.
reference_records
?
record
.
metadata
.
reference_records
...
...
src/pages/problem_rectification/list/index.jsx
View file @
e2c3190a
...
@@ -46,7 +46,7 @@ import {
...
@@ -46,7 +46,7 @@ import {
}
from
'@heroui/react'
;
}
from
'@heroui/react'
;
import
clsx
from
'clsx'
;
import
clsx
from
'clsx'
;
import
dayjs
from
'dayjs'
;
import
dayjs
from
'dayjs'
;
import
{
useCallback
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useCallback
,
useEffect
,
useMemo
,
use
Ref
,
use
State
}
from
'react'
;
import
{
toast
}
from
'@heroui/react'
;
import
{
toast
}
from
'@heroui/react'
;
import
{
import
{
formatFieldValue
,
formatFieldValue
,
...
@@ -820,6 +820,8 @@ export default function ProblemRectificationListPage() {
...
@@ -820,6 +820,8 @@ export default function ProblemRectificationListPage() {
totalPages: 1,
totalPages: 1,
});
});
const [gridViewCounts, setGridViewCounts] = useState({});
const [gridViewCounts, setGridViewCounts] = useState({});
const viewCountCacheRef = useRef(new Map());
const listRequestIdRef = useRef(0);
const [workflowUserLabels, setWorkflowUserLabels] = useState({});
const [workflowUserLabels, setWorkflowUserLabels] = useState({});
const [maintenanceFlowByRecordId, setMaintenanceFlowByRecordId] = useState({});
const [maintenanceFlowByRecordId, setMaintenanceFlowByRecordId] = useState({});
const createFieldVariant = 'secondary';
const createFieldVariant = 'secondary';
...
@@ -984,7 +986,9 @@ export default function ProblemRectificationListPage() {
...
@@ -984,7 +986,9 @@ export default function ProblemRectificationListPage() {
const reloadList = useCallback(
const reloadList = useCallback(
async (overrides = {}) => {
async (overrides = {}) => {
const requestId = ++listRequestIdRef.current;
if (!subject || !activeGridView) {
if (!subject || !activeGridView) {
viewCountCacheRef.current = new Map();
setRecords([]);
setRecords([]);
setGridViewCounts({});
setGridViewCounts({});
setPagination({
setPagination({
...
@@ -999,6 +1003,11 @@ export default function ProblemRectificationListPage() {
...
@@ -999,6 +1003,11 @@ export default function ProblemRectificationListPage() {
const nextPage = overrides.page ?? page;
const nextPage = overrides.page ?? page;
const nextPageSize = overrides.pageSize ?? pageSize;
const nextPageSize = overrides.pageSize ?? pageSize;
const nextFilters = overrides.filters ?? filters;
const nextFilters = overrides.filters ?? filters;
// 数据操作和手动刷新重新统计;Tab、分页及相同筛选复用查询条件对应的数量。
if (overrides.refreshCounts !== false) {
viewCountCacheRef.current = new Map();
}
const countCache = viewCountCacheRef.current;
setRecordsLoading(true);
setRecordsLoading(true);
setError('');
setError('');
...
@@ -1014,13 +1023,18 @@ export default function ProblemRectificationListPage() {
...
@@ -1014,13 +1023,18 @@ export default function ProblemRectificationListPage() {
filters: nextFilters,
filters: nextFilters,
gridView: activeGridView,
gridView: activeGridView,
subject,
subject,
countCache,
}),
}),
loadProblemRectificationViewCountMap({
loadProblemRectificationViewCountMap({
filters: nextFilters,
filters: nextFilters,
views: gridViews,
views: gridViews
.filter((view) => view.name !== activeGridView.name)
,
subject,
subject,
countCache,
}),
}),
]);
]);
if (requestId !== listRequestIdRef.current) {
return;
}
const nextPagination = pageResult.pagination || {
const nextPagination = pageResult.pagination || {
page: nextPage,
page: nextPage,
...
@@ -1031,7 +1045,10 @@ export default function ProblemRectificationListPage() {
...
@@ -1031,7 +1045,10 @@ export default function ProblemRectificationListPage() {
setRecords(pageResult.records || []);
setRecords(pageResult.records || []);
setPagination(nextPagination);
setPagination(nextPagination);
setGridViewCounts(countMap || {});
setGridViewCounts({
...countMap,
[activeGridView.name]: nextPagination.totalRecords,
});
if (
if (
nextPagination.totalRecords > 0 &&
nextPagination.totalRecords > 0 &&
...
@@ -1041,6 +1058,9 @@ export default function ProblemRectificationListPage() {
...
@@ -1041,6 +1058,9 @@ export default function ProblemRectificationListPage() {
setPage(nextPagination.totalPages - 1);
setPage(nextPagination.totalPages - 1);
}
}
} catch (requestError) {
} catch (requestError) {
if (requestId !== listRequestIdRef.current) {
return;
}
setRecords([]);
setRecords([]);
setGridViewCounts({});
setGridViewCounts({});
setPagination({
setPagination({
...
@@ -1051,14 +1071,19 @@ export default function ProblemRectificationListPage() {
...
@@ -1051,14 +1071,19 @@ export default function ProblemRectificationListPage() {
});
});
setError(requestError?.message || '问题整改列表加载失败,请稍后重试。');
setError(requestError?.message || '问题整改列表加载失败,请稍后重试。');
} finally {
} finally {
setRecordsLoading(false);
if (requestId === listRequestIdRef.current) {
setRecordsLoading(false);
}
}
}
},
},
[activeGridView, filters, gridViews, page, pageSize, subject],
[activeGridView, filters, gridViews, page, pageSize, subject],
);
);
useEffect(() => {
useEffect(() => {
reloadList();
reloadList({ refreshCounts: false });
return () => {
listRequestIdRef.current += 1;
};
}, [reloadList]);
}, [reloadList]);
const configuredView = useMemo(
const configuredView = useMemo(
...
...
src/pages/problem_rectification/problem-rectification.business-flow.md
View file @
e2c3190a
...
@@ -88,7 +88,8 @@
...
@@ -88,7 +88,8 @@
-
`problem_rectification`
主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、视图字段筛选条件和页码调用
`/record/query`
,分页参数使用
`page`
与
`page_size`
-
`problem_rectification`
主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、视图字段筛选条件和页码调用
`/record/query`
,分页参数使用
`page`
与
`page_size`
-
主列表提供“检查报告出具月份”筛选,用户选择
`YYYY-MM`
后,仅查询
`metadata.inspection_report_date`
位于该月份的记录;该筛选不使用其它检查或整改日期兜底
-
主列表提供“检查报告出具月份”筛选,用户选择
`YYYY-MM`
后,仅查询
`metadata.inspection_report_date`
位于该月份的记录;该筛选不使用其它检查或整改日期兜底
-
主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。
-
主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。
-
主列表视图页签数量通过
`/record/count`
按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数
-
主列表当前视图的页签数量、表格总数和分页复用
`/record/query`
返回的
`total_records`
(兼容
`total_count`
/
`total`
),不再额外调用
`/record/count`
。其他有权限的视图按视图限制和当前筛选条件统计,并在页面会话内按实际查询条件缓存;相同条件的并发统计共用一个请求。切换 Tab、翻页或切换每页条数时复用缓存,新的筛选条件只统计尚未缓存的查询。切换 Tab 仍清空筛选并回到第 1 页。
-
首次加载、新筛选条件、视图配置变化时补齐其他视图的数量;手动刷新或新增、复制、删除、详情保存、锁定、推送等数据操作后清空数量缓存,再读取当前视图列表并统计其他视图,避免数量过期。列表加载期间保留 DataGrid loading 状态,失败时显示现有错误提示;失败的统计请求不保留缓存,允许重试。快速切换时只应用最后一次列表请求的结果,旧响应不得覆盖当前列表和页签数量。
-
主列表显示标题列时,标题单元格直接展示可点击的延期申请状态:存在历史问题级延期或部门级延期明细时,按当前阶段显示“延期已发起 / 延期待审批 / 延期待终审 / 延期处理中 / 延期已生效 / 延期已退回”日历徽标,不能等用户再次点击推送后才提示已发起;状态同时使用图标和文字,不只依赖颜色。视图启用
`shortTitle`
时,“问题动态”列按相同规则直接展示延期状态和问题明细数量;没有明细或延期时明确显示“暂无问题明细”“未发起延期”,操作列仍保留“查看延期申请”入口
-
主列表显示标题列时,标题单元格直接展示可点击的延期申请状态:存在历史问题级延期或部门级延期明细时,按当前阶段显示“延期已发起 / 延期待审批 / 延期待终审 / 延期处理中 / 延期已生效 / 延期已退回”日历徽标,不能等用户再次点击推送后才提示已发起;状态同时使用图标和文字,不只依赖颜色。视图启用
`shortTitle`
时,“问题动态”列按相同规则直接展示延期状态和问题明细数量;没有明细或延期时明确显示“暂无问题明细”“未发起延期”,操作列仍保留“查看延期申请”入口
-
点击延期状态后打开“延期申请详情”弹窗,按责任部门展示原预计完成时间、申请完成时间、延期事由、填写人、一级审批人、最终审批人以及三级流程状态;申请完成时间或延期事由尚未提交时明确显示“待填写”,历史问题级延期按“整条问题”展示;行操作区同时提供“查看延期申请”日历按钮,保证窄屏下标题列被固定列压缩时仍可进入详情
-
点击延期状态后打开“延期申请详情”弹窗,按责任部门展示原预计完成时间、申请完成时间、延期事由、填写人、一级审批人、最终审批人以及三级流程状态;申请完成时间或延期事由尚未提交时明确显示“待填写”,历史问题级延期按“整条问题”展示;行操作区同时提供“查看延期申请”日历按钮,保证窄屏下标题列被固定列压缩时仍可进入详情
-
用户单选已有延期申请的记录时,顶部“推送延期申请”按钮改为显示当前延期状态并禁用,避免形成看似可以重复推送的入口
-
用户单选已有延期申请的记录时,顶部“推送延期申请”按钮改为显示当前延期状态并禁用,避免形成看似可以重复推送的入口
...
@@ -515,7 +516,7 @@
...
@@ -515,7 +516,7 @@
-
明细转换:按
`problem_rectification_archive_records`
的 51 个字段从主记录
`metadata`
生成独立快照;
`responsible_department`
转为逗号拼接文本;
`fill_in_the_people`
、
`review_people`
保持
`user`
类型并只保存用户
`_id[]`
;
`rectification_proof_materials`
作为文件对象保留;日期字段继续保存 ISO 8601 字符串
-
明细转换:按
`problem_rectification_archive_records`
的 51 个字段从主记录
`metadata`
生成独立快照;
`responsible_department`
转为逗号拼接文本;
`fill_in_the_people`
、
`review_people`
保持
`user`
类型并只保存用户
`_id[]`
;
`rectification_proof_materials`
作为文件对象保留;日期字段继续保存 ISO 8601 字符串
-
嵌套快照:
`department_rectification_items`
与
`problem_rectification_delay_request_items`
在归档明细中均使用多值
`object`
,归档时深拷贝整组对象,不使用
`subject`
或
`reference`
关联原问题中的嵌套数据
-
嵌套快照:
`department_rectification_items`
与
`problem_rectification_delay_request_items`
在归档明细中均使用多值
`object`
,归档时深拷贝整组对象,不使用
`subject`
或
`reference`
关联原问题中的嵌套数据
-
批次写入:归档明细导入成功后创建 1 条
`issue_rectification_archive`
批次记录,标题格式为“问题整改归档 YYYY-MM-DD”,并在
`metadata.reference_records`
中保存本批次所有归档明细
`_id`
-
批次写入:归档明细导入成功后创建 1 条
`issue_rectification_archive`
批次记录,标题格式为“问题整改归档 YYYY-MM-DD”,并在
`metadata.reference_records`
中保存本批次所有归档明细
`_id`
-
归档查看:
`/issue_rectification_archive`
展示归档批次列表;批次详情通过
`reference_records`
追溯归档明细数量和具体快照内容
-
归档查看:
`/issue_rectification_archive`
展示归档批次列表;批次详情通过
`reference_records`
追溯归档明细数量和具体快照内容
。批次详情支持全屏切换,两组嵌套明细以条数入口打开只读结构化快照,具体交互与异常处理见 10.3
-
异常限制:归档是一次性历史快照,原问题记录后续编辑不会反向更新归档明细;若归档明细导入失败,不创建批次;若批次创建失败,需要提示用户本次归档未完整闭环,避免出现孤立快照无人可查
-
异常限制:归档是一次性历史快照,原问题记录后续编辑不会反向更新归档明细;若归档明细导入失败,不创建批次;若批次创建失败,需要提示用户本次归档未完整闭环,避免出现孤立快照无人可查
---
---
...
@@ -741,6 +742,15 @@
...
@@ -741,6 +742,15 @@
-
归档明细导入失败时不创建归档批次;归档批次创建失败时应提示用户归档未完整完成,避免出现无法从批次入口追溯的孤立明细
-
归档明细导入失败时不创建归档批次;归档批次创建失败时应提示用户归档未完整完成,避免出现无法从批次入口追溯的孤立明细
-
归档期间按钮进入加载状态并禁止重复提交;读取原记录条数、导入返回的归档明细 ID 数量与用户选择数必须一致,否则中止创建批次并提示刷新或联系管理员处理
-
归档期间按钮进入加载状态并禁止重复提交;读取原记录条数、导入返回的归档明细 ID 数量与用户选择数必须一致,否则中止创建批次并提示刷新或联系管理员处理
### 10.3 归档详情查看
-
功能入口:
`/issue_rectification_archive`
批次列表的“查看详情”打开“归档批次详情”,通过批次
`metadata.reference_records`
展示归档问题列表。
-
批次详情右上角提供“全屏查看 / 退出全屏”和关闭按钮;全屏沿用 HeroUI Modal
`full`
,恢复时回到 1088px 基准弹窗,正文内部滚动,宽表仅在表格内部横向滚动。
-
归档问题的
`department_rectification_items`
、
`problem_rectification_delay_request_items`
列显示明细条数入口,不直接输出 JSON;无数据时分别显示“暂无部门整改明细”“暂无问题整改延期”。点击入口打开只读明细弹窗,支持独立全屏和关闭,关闭后返回原批次详情。
-
明细弹窗按条目/责任部门分组展示标签和值。打开时经 service 层调用
`loadSubject`
读取
`department_rectification_items`
或
`problem_rectification_delay_request`
的字段定义,字段名称、类型、顺序与标签以实时定义为准;字段值始终取自归档快照。部门明细包含整改进度、整改情况、预计完成时间、填写/审批状态、人员和证明材料;延期明细包含原预计完成时间、申请延期至、延期事由、审批人员/状态和审批时间。日期按日期展示,人员对象显示姓名,附件通过归档文件地址查看。
-
查看兼容对象数组、单对象、历史 JSON 字符串、数组中的 JSON 字符串及
`metadata`
包裹的嵌套对象,仅在内存中解析展示,不回写归档或原问题。打开、全屏切换、关闭均为本地查看状态,不改变整改或延期审批状态,也不读取原问题的当前值覆盖历史快照。
-
读取字段期间展示加载状态;字段请求失败或定义为空时保留弹窗并显示错误及“重试”。历史 JSON 损坏或条目类型异常时显示“查看异常数据”入口及明确错误,不静默丢弃数据,不显示误导性的零条明细。空字段显示“暂无内容”。
---
---
## 11. 页面与交互重构建议
## 11. 页面与交互重构建议
...
@@ -827,7 +837,7 @@
...
@@ -827,7 +837,7 @@
### 11.5 归档页
### 11.5 归档页
-
首页展示归档批次列表
-
首页展示归档批次列表
-
点击批次
进入右侧详情抽屉或二级详情页
-
点击批次
打开支持全屏查看的详情弹窗,交互以 10.3 为准
-
批次详情内展示:
-
批次详情内展示:
-
归档标题
-
归档标题
-
归档时间
-
归档时间
...
...
src/pages/problem_rectification/problemRectificationListService.js
View file @
e2c3190a
...
@@ -1307,30 +1307,37 @@ export async function loadProblemRectificationListData({
...
@@ -1307,30 +1307,37 @@ export async function loadProblemRectificationListData({
filters
=
{},
filters
=
{},
gridView
=
null
,
gridView
=
null
,
subject
=
null
,
subject
=
null
,
countCache
=
null
,
}
=
{})
{
}
=
{})
{
const
requestPage
=
Math
.
max
(
0
,
Number
(
page
)
||
0
);
const
requestPage
=
Math
.
max
(
0
,
Number
(
page
)
||
0
);
const
requestPageSize
=
PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS
.
includes
(
Number
(
pageSize
))
const
requestPageSize
=
PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS
.
includes
(
Number
(
pageSize
))
?
Number
(
pageSize
)
?
Number
(
pageSize
)
:
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE
;
:
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE
;
const
result
=
await
queryRecords
(
{
const
countQuery
=
{
subject
:
PROBLEM_RECTIFICATION_SUBJECT
,
subject
:
PROBLEM_RECTIFICATION_SUBJECT
,
filter
:
buildProblemRectificationQueryFilter
(
filters
,
gridView
,
subject
),
};
const
result
=
await
queryRecords
({
...
countQuery
,
page
:
requestPage
,
page
:
requestPage
,
page_size
:
requestPageSize
,
page_size
:
requestPageSize
,
filter
:
buildProblemRectificationQueryFilter
(
filters
,
gridView
,
subject
),
sort
:
buildProblemRectificationSort
(
gridView
),
sort
:
buildProblemRectificationSort
(
gridView
),
mode
:
'full'
,
mode
:
'full'
,
});
});
const
records
=
(
result
?.
records
||
[]).
map
(
normalizeProblemRectificationRecord
);
const
records
=
(
result
?.
records
||
[]).
map
(
normalizeProblemRectificationRecord
);
const
totalRecords
=
Number
(
result
?.
total_records
??
result
?.
total
??
records
.
length
);
const
totalRecords
=
Number
(
result
?.
total_records
??
result
?.
total_count
??
result
?.
total
??
records
.
length
,
);
const
totalPages
=
Number
(
const
totalPages
=
Number
(
result
?.
total_pages
??
Math
.
max
(
1
,
Math
.
ceil
(
totalRecords
/
requestPageSize
)),
result
?.
total_pages
??
Math
.
max
(
1
,
Math
.
ceil
(
totalRecords
/
requestPageSize
)),
);
);
countCache
?.
set
(
JSON
.
stringify
(
countQuery
),
Promise
.
resolve
(
totalRecords
));
return
{
return
{
records
,
records
,
pagination
:
{
pagination
:
{
page
:
Number
(
result
?.
page
??
requestPage
),
page
:
Number
(
result
?.
current_page
??
result
?.
page
??
requestPage
),
pageSize
:
Number
(
result
?.
page_size
??
requestPageSize
),
pageSize
:
Number
(
result
?.
page_size
??
requestPageSize
),
totalRecords
,
totalRecords
,
totalPages
:
Math
.
max
(
1
,
totalPages
),
totalPages
:
Math
.
max
(
1
,
totalPages
),
...
@@ -1371,15 +1378,34 @@ export async function loadProblemRectificationListResources() {
...
@@ -1371,15 +1378,34 @@ export async function loadProblemRectificationListResources() {
};
};
}
}
export
async
function
loadProblemRectificationViewCountMap
({
filters
=
{},
views
=
[],
subject
=
null
}
=
{})
{
export
async
function
loadProblemRectificationViewCountMap
({
filters
=
{},
views
=
[],
subject
=
null
,
countCache
=
new
Map
(),
}
=
{})
{
const
entries
=
await
Promise
.
all
(
const
entries
=
await
Promise
.
all
(
views
.
map
(
async
(
view
)
=>
{
views
.
map
(
async
(
view
)
=>
{
const
result
=
await
countRecords
(
{
const
countQuery
=
{
subject
:
PROBLEM_RECTIFICATION_SUBJECT
,
subject
:
PROBLEM_RECTIFICATION_SUBJECT
,
filter
:
buildProblemRectificationQueryFilter
(
filters
,
view
,
subject
),
filter
:
buildProblemRectificationQueryFilter
(
filters
,
view
,
subject
),
});
};
const
countKey
=
JSON
.
stringify
(
countQuery
);
if
(
!
countCache
.
has
(
countKey
))
{
const
countRequest
=
countRecords
(
countQuery
)
.
then
((
result
)
=>
Number
(
result
?.
count
??
result
?.
total
??
0
))
.
catch
((
error
)
=>
{
if
(
countCache
.
get
(
countKey
)
===
countRequest
)
{
countCache
.
delete
(
countKey
);
}
throw
error
;
});
countCache
.
set
(
countKey
,
countRequest
);
}
return
[
view
.
name
,
Number
(
result
?.
count
??
result
?.
total
??
0
)];
await
countCache
.
get
(
countKey
);
// 等待统计期间,同条件的列表响应可能已用更新的总数替换缓存。
return
[
view
.
name
,
await
countCache
.
get
(
countKey
)];
}),
}),
);
);
...
...
src/services/record-api.md
View file @
e2c3190a
...
@@ -197,6 +197,20 @@ const response = await queryRecords({
...
@@ -197,6 +197,20 @@ const response = await queryRecords({
});
});
```
```
**分页响应结构**
(记录内容省略):
```
json
{
"total_records"
:
19
,
"records"
:
[],
"current_page"
:
0
,
"page_size"
:
100
,
"total_pages"
:
1
}
```
`total_records`
是当前查询条件下的记录总数,与当前页
`records.length`
不同;列表分页和当前视图数量可直接复用该值,无需再对相同条件调用
`/record/count`
。
#### 3.1.2 POST `/record/query/removed`
#### 3.1.2 POST `/record/query/removed`
权限:
`api.record.removed.query`
+ 管理员
权限:
`api.record.removed.query`
+ 管理员
...
...
tests/archiveSnapshotItems.test.mjs
0 → 100644
View file @
e2c3190a
import
assert
from
'node:assert/strict'
;
import
test
from
'node:test'
;
import
{
normalizeArchiveSnapshotItems
}
from
'../src/pages/problem_rectification/archive/archiveSnapshotItems.mjs'
;
test
(
'reads structured snapshots and legacy JSON without changing archived values'
,
()
=>
{
const
items
=
[{
item_id
:
'dept-1'
,
responsible_department
:
'审计部'
,
rectification_progress
:
'已完成'
}];
assert
.
deepEqual
(
normalizeArchiveSnapshotItems
(
items
),
items
);
assert
.
deepEqual
(
normalizeArchiveSnapshotItems
(
JSON
.
stringify
(
items
)),
items
);
assert
.
deepEqual
(
normalizeArchiveSnapshotItems
(
items
.
map
(
JSON
.
stringify
)),
items
);
assert
.
deepEqual
(
normalizeArchiveSnapshotItems
(
items
[
0
]),
items
);
assert
.
deepEqual
(
normalizeArchiveSnapshotItems
(
JSON
.
stringify
(
items
[
0
])),
items
);
});
test
(
'reads metadata-wrapped embedded snapshots while preserving false and ISO dates'
,
()
=>
{
const
metadata
=
{
original_due_date
:
'2026-10-07T00:00:00.000Z'
,
requested_due_date
:
'2026-11-07T00:00:00.000Z'
,
is_locked
:
false
,
delay_reason
:
'待完成系统建设'
,
};
const
input
=
{
item_id
:
'delay-1'
,
metadata
};
assert
.
deepEqual
(
normalizeArchiveSnapshotItems
([
input
]),
[{
item_id
:
'delay-1'
,
...
metadata
}]);
assert
.
deepEqual
(
input
,
{
item_id
:
'delay-1'
,
metadata
});
});
test
(
'treats empty snapshots as empty lists'
,
()
=>
{
for
(
const
value
of
[
undefined
,
null
,
''
,
' '
,
'null'
,
'[]'
,
[]])
{
assert
.
deepEqual
(
normalizeArchiveSnapshotItems
(
value
),
[]);
}
});
test
(
'reports damaged snapshots instead of silently discarding historical rows'
,
()
=>
{
assert
.
throws
(()
=>
normalizeArchiveSnapshotItems
(
'[{'
),
/JSON 格式/
);
for
(
const
value
of
[
false
,
12
,
[
null
],
[[],
{}],
[
'broken'
],
'[{},42]'
])
{
assert
.
throws
(()
=>
normalizeArchiveSnapshotItems
(
value
),
/无法|JSON/
);
}
});
tests/problemRectificationListRequests.test.mjs
0 → 100644
View file @
e2c3190a
import
assert
from
'node:assert/strict'
;
import
{
readFileSync
}
from
'node:fs'
;
import
{
createRequire
}
from
'node:module'
;
import
{
dirname
,
resolve
}
from
'node:path'
;
import
test
from
'node:test'
;
import
ts
from
'typescript'
;
const
require
=
createRequire
(
import
.
meta
.
url
);
const
sourceRoot
=
new
URL
(
'../src/'
,
import
.
meta
.
url
).
pathname
;
const
servicePath
=
resolve
(
sourceRoot
,
'pages/problem_rectification/problemRectificationListService.js'
);
// 使用真实的筛选、记录归一化和分页逻辑,只替换 HTTP service 边界。
function
loadListService
(
dataService
)
{
const
modules
=
new
Map
();
const
loadModule
=
(
path
)
=>
{
if
(
modules
.
has
(
path
))
return
modules
.
get
(
path
);
const
exports
=
{};
modules
.
set
(
path
,
exports
);
const
{
outputText
}
=
ts
.
transpileModule
(
readFileSync
(
path
,
'utf8'
),
{
compilerOptions
:
{
module
:
ts
.
ModuleKind
.
CommonJS
,
target
:
ts
.
ScriptTarget
.
ES2022
,
esModuleInterop
:
true
,
},
});
const
localRequire
=
(
specifier
)
=>
{
if
(
specifier
===
'@/services/DataService'
)
return
dataService
;
if
(
!
specifier
.
startsWith
(
'.'
)
&&
!
specifier
.
startsWith
(
'@/'
))
return
require
(
specifier
);
const
dependencyPath
=
specifier
.
startsWith
(
'@/'
)
?
resolve
(
sourceRoot
,
specifier
.
slice
(
2
))
:
resolve
(
dirname
(
path
),
specifier
);
return
dependencyPath
.
endsWith
(
'.mjs'
)
?
require
(
dependencyPath
)
:
loadModule
(
`
${
dependencyPath
}
.js`
);
};
new
Function
(
'require'
,
'exports'
,
outputText
)(
localRequire
,
exports
);
return
exports
;
};
return
loadModule
(
servicePath
);
}
const
VIEWS
=
[
{
name
:
'all'
,
settings
:
{}
},
...[
'内部检查'
,
'监管检查'
].
map
((
value
,
index
)
=>
({
name
:
`source-
${
index
}
`
,
settings
:
{
restrictions
:
[{
type
:
'field'
,
field
:
'metadata.problem_from'
,
comparator
:
'$eq'
,
value
}],
},
})),
];
test
(
'query totals drive pagination and counts without counting the current view again'
,
async
()
=>
{
const
service
=
loadListService
({
queryRecords
:
async
()
=>
({
records
:
[{
_id
:
'record-1'
,
metadata
:
{}
}],
total_records
:
41
,
current_page
:
1
,
page_size
:
20
,
total_pages
:
3
,
}),
countRecords
:
async
()
=>
{
throw
new
Error
(
'不应调用 count'
);
},
});
const
countCache
=
new
Map
();
const
result
=
await
service
.
loadProblemRectificationListData
({
gridView
:
VIEWS
[
0
],
countCache
});
assert
.
equal
(
result
.
records
.
length
,
1
);
assert
.
deepEqual
(
result
.
pagination
,
{
page
:
1
,
pageSize
:
20
,
totalRecords
:
41
,
totalPages
:
3
});
assert
.
deepEqual
(
await
service
.
loadProblemRectificationViewCountMap
({
views
:
[
VIEWS
[
0
]],
countCache
}),
{
all
:
41
},
);
});
test
(
'tab switches and pagination reuse counts; new filters and invalidation refresh them'
,
async
()
=>
{
const
queries
=
[];
const
counts
=
[];
const
service
=
loadListService
({
queryRecords
:
async
(
query
)
=>
{
queries
.
push
(
query
);
return
{
records
:
[],
total_records
:
41
};
},
countRecords
:
async
(
query
)
=>
{
counts
.
push
(
query
);
return
{
count
:
12
};
},
});
let
countCache
=
new
Map
();
const
loadPage
=
async
(
gridView
,
page
=
0
,
filters
=
{})
=>
Promise
.
all
([
service
.
loadProblemRectificationListData
({
gridView
,
page
,
filters
,
countCache
}),
service
.
loadProblemRectificationViewCountMap
({
views
:
VIEWS
.
filter
((
view
)
=>
view
.
name
!==
gridView
.
name
),
filters
,
countCache
,
}),
]);
await
loadPage
(
VIEWS
[
0
]);
assert
.
equal
(
counts
.
length
,
2
);
await
loadPage
(
VIEWS
[
1
]);
await
loadPage
(
VIEWS
[
2
],
1
);
assert
.
equal
(
counts
.
length
,
2
);
assert
.
equal
(
queries
.
length
,
3
);
assert
.
equal
(
queries
[
2
].
page
,
1
);
await
loadPage
(
VIEWS
[
2
],
0
,
{
progress
:
'整改中'
});
assert
.
equal
(
counts
.
length
,
4
);
assert
.
ok
(
counts
.
slice
(
2
).
every
((
query
)
=>
JSON
.
stringify
(
query
.
filter
).
includes
(
'整改中'
)));
countCache
=
new
Map
();
await
loadPage
(
VIEWS
[
2
]);
assert
.
equal
(
counts
.
length
,
6
);
});
test
(
'total_count is supported and a zero total never falls back to another field'
,
async
()
=>
{
for
(
const
[
response
,
expected
]
of
[
[{
total_count
:
51
},
51
],
[{
total_records
:
0
,
total_count
:
51
},
0
],
])
{
const
service
=
loadListService
({
queryRecords
:
async
()
=>
({
records
:
[],
...
response
})
});
const
result
=
await
service
.
loadProblemRectificationListData
();
assert
.
equal
(
result
.
pagination
.
totalRecords
,
expected
);
assert
.
equal
(
result
.
pagination
.
totalPages
,
Math
.
max
(
1
,
Math
.
ceil
(
expected
/
20
)));
}
});
test
(
'concurrent requests and equivalent view filters share a single count request'
,
async
()
=>
{
let
callCount
=
0
;
let
finishCount
;
const
service
=
loadListService
({
countRecords
:
()
=>
{
callCount
+=
1
;
return
new
Promise
((
resolveCount
)
=>
{
finishCount
=
resolveCount
;
});
},
});
const
countCache
=
new
Map
();
const
views
=
[
VIEWS
[
0
],
{
...
VIEWS
[
0
],
name
:
'same-filter'
}];
const
first
=
service
.
loadProblemRectificationViewCountMap
({
views
,
countCache
});
const
second
=
service
.
loadProblemRectificationViewCountMap
({
views
,
countCache
});
assert
.
equal
(
callCount
,
1
);
finishCount
({
count
:
19
});
for
(
const
result
of
await
Promise
.
all
([
first
,
second
]))
{
assert
.
deepEqual
(
result
,
{
all
:
19
,
'same-filter'
:
19
});
}
});
test
(
'failed counts are retried instead of cached permanently'
,
async
()
=>
{
let
callCount
=
0
;
const
service
=
loadListService
({
countRecords
:
async
()
=>
{
callCount
+=
1
;
if
(
callCount
===
1
)
throw
new
Error
(
'统计失败'
);
return
{
count
:
19
};
},
});
const
options
=
{
views
:
[
VIEWS
[
0
]],
countCache
:
new
Map
()
};
await
assert
.
rejects
(
service
.
loadProblemRectificationViewCountMap
(
options
),
/统计失败/
);
assert
.
deepEqual
(
await
service
.
loadProblemRectificationViewCountMap
(
options
),
{
all
:
19
});
assert
.
equal
(
callCount
,
2
);
});
test
(
'a late count response cannot replace the newer query total'
,
async
()
=>
{
let
finishCount
;
const
service
=
loadListService
({
countRecords
:
()
=>
new
Promise
((
resolveCount
)
=>
{
finishCount
=
resolveCount
;
}),
queryRecords
:
async
()
=>
({
records
:
[],
total_records
:
22
}),
});
const
countCache
=
new
Map
();
const
pendingCounts
=
service
.
loadProblemRectificationViewCountMap
({
views
:
[
VIEWS
[
0
]],
countCache
});
await
service
.
loadProblemRectificationListData
({
gridView
:
VIEWS
[
0
],
countCache
});
finishCount
({
count
:
19
});
assert
.
deepEqual
(
await
pendingCounts
,
{
all
:
22
});
});
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