Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
I
integrity_practice_management_new
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
integrity_practice_management_new
Commits
f99189d9
Commit
f99189d9
authored
Oct 09, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: 完善引用选择与表格列分组显示
parent
b08715fd
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
369 additions
and
65 deletions
+369
-65
manifest.json
public/manifest.json
+1
-1
ReferenceSearchSelect.jsx
src/components/ReferenceSearchSelect.jsx
+65
-55
index.jsx
src/components/record-workbench/index.jsx
+213
-7
shell.css
src/design-system/shell.css
+45
-0
SubjectWorkbench.jsx
src/pages/integrity_practice_management/SubjectWorkbench.jsx
+16
-2
integrity-practice-management.business-flow.md
...management/integrity-practice-management.business-flow.md
+13
-0
subjectView.js
src/utils/subjectView.js
+1
-0
tableFieldDisplay.test.mjs
tests/tableFieldDisplay.test.mjs
+15
-0
No files found.
public/manifest.json
View file @
f99189d9
{
{
"name"
:
"integrity_practice_management_new"
,
"name"
:
"integrity_practice_management_new"
,
"version"
:
"1.0.1
1
"
,
"version"
:
"1.0.1
3
"
,
"build"
:
"abc"
,
"build"
:
"abc"
,
"description"
:
"廉洁从业管理"
,
"description"
:
"廉洁从业管理"
,
"date"
:
"2025-07-14 19:27:20"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/components/ReferenceSearchSelect.jsx
View file @
f99189d9
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete
'
;
import
{
ChevronDown
,
Close
,
Search
}
from
'@/components/AppIcons
'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
queryRecords
}
from
'@/services/DataService'
;
import
{
queryRecords
}
from
'@/services/DataService'
;
import
{
Label
}
from
'@heroui/react'
;
import
{
Button
,
Label
,
ListBox
,
Popover
,
SearchField
,
Spinner
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
useEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
const
DEFAULT_ID_LOOKUP_CHUNK_SIZE
=
50
;
const
DEFAULT_ID_LOOKUP_CHUNK_SIZE
=
50
;
...
@@ -541,26 +541,16 @@ export default function ReferenceSearchSelect({
...
@@ -541,26 +541,16 @@ export default function ReferenceSearchSelect({
.
join
(
'、'
)}
等
${
selectedOptions
.
length
}
项`
.
join
(
'、'
)}
等
${
selectedOptions
.
length
}
项`
:
selectedOptions
[
0
]?.
label
||
placeholder
;
:
selectedOptions
[
0
]?.
label
||
placeholder
;
const
selectionProps
=
multiple
const
handleSelectionChange
=
(
keys
)
=>
{
?
{
const
nextIds
=
keys
===
'all'
?
menuOptions
.
map
((
item
)
=>
item
.
id
)
:
Array
.
from
(
keys
||
[]).
map
(
String
);
selectedKeys
:
new
Set
(
selectedIds
),
commitChange
(
nextIds
);
onSelectionChange
:
(
keys
)
=>
{
if
(
!
multiple
)
handleOpenChange
(
false
);
const
nextIds
=
};
keys
===
'all'
?
menuOptions
.
map
((
item
)
=>
item
.
id
)
:
[...
keys
];
commitChange
(
nextIds
);
},
}
:
{
selectedKey
:
selectedIds
[
0
]
||
null
,
onSelectionChange
:
(
key
)
=>
{
const
nextKey
=
key
?
String
(
key
)
:
''
;
commitChange
(
nextKey
?
[
String
(
nextKey
)]
:
[]);
setActive
(
false
);
},
};
const
handleOpenChange
=
(
isOpen
)
=>
{
const
handleOpenChange
=
(
isOpen
)
=>
{
if
(
disabled
)
return
;
setActive
(
isOpen
);
setActive
(
isOpen
);
if
(
isOpen
)
setLoading
(
true
);
if
(
!
isOpen
)
{
if
(
!
isOpen
)
{
setQuery
(
''
);
setQuery
(
''
);
setError
(
''
);
setError
(
''
);
...
@@ -568,42 +558,62 @@ export default function ReferenceSearchSelect({
...
@@ -568,42 +558,62 @@ export default function ReferenceSearchSelect({
};
};
return
(
return
(
<
XinyuanAutocomplete
<
Popover
.
Root
isOpen=
{
active
}
onOpenChange=
{
handleOpenChange
}
>
{
...
selectionProps
}
<
Button
aria
-
label=
{
placeholder
}
aria
-
label=
{
placeholder
}
emptyText=
"无匹配结果"
className=
"rc-reference-select-trigger"
errorText=
{
error
}
fullWidth
inputValue=
{
query
}
isDisabled=
{
disabled
}
isDisabled=
{
disabled
}
variant=
{
variant
||
'secondary'
}
isClearable=
{
allowClear
}
>
isLoading=
{
loading
}
<
span
className=
"rc-reference-select-trigger__value"
title=
{
selectedOptions
.
map
((
option
)
=>
option
.
label
).
join
(
'、'
)
||
placeholder
}
>
isOpen=
{
active
}
{
displayText
}
options=
{
menuOptions
}
</
span
>
placement=
{
optionsAnchor
||
'bottom start'
}
<
span
className=
"rc-reference-select-trigger__indicator"
><
ChevronDown
size=
{
16
}
/></
span
>
placeholder=
{
placeholder
}
</
Button
>
searchPlaceholder=
{
placeholder
}
<
Popover
.
Content
className=
"rc-reference-select-popover"
placement=
{
optionsAnchor
||
'bottom start'
}
>
selectionMode=
{
multiple
?
'multiple'
:
'single'
}
<
Popover
.
Dialog
aria
-
label=
{
placeholder
}
className=
"min-w-0 p-0"
>
shouldFilter=
{
false
}
<
div
className=
"flex items-center justify-between gap-3 px-3 pb-2 pt-3"
>
valueText=
{
displayText
}
<
span
className=
"text-sm text-muted"
>
已选择
{
selectedIds
.
length
}
项
</
span
>
variant=
{
variant
}
{
allowClear
?
<
Button
size=
"sm"
variant=
"tertiary"
onPress=
{
clearSelection
}
>
清空
</
Button
>
:
null
}
onInputChange=
{
setQuery
}
</
div
>
onClear=
{
clearSelection
}
<
div
className=
"px-2 pb-2"
>
onOpenChange=
{
handleOpenChange
}
<
SearchField
aria
-
label=
"搜索引用记录"
fullWidth
value=
{
query
}
onChange=
{
setQuery
}
variant=
{
variant
||
'secondary'
}
>
renderOption=
{
(
option
)
=>
(
<
SearchField
.
Group
>
<
div
className=
"min-w-0 flex-1 overflow-hidden"
>
<
SearchField
.
SearchIcon
><
Search
size=
{
16
}
/></
SearchField
.
SearchIcon
>
<
Label
className=
"block truncate"
title=
{
option
.
label
}
>
<
SearchField
.
Input
autoFocus
placeholder=
{
placeholder
}
/>
{
option
.
label
}
<
SearchField
.
ClearButton
aria
-
label=
"清空搜索"
><
Close
size=
{
14
}
/></
SearchField
.
ClearButton
>
</
Label
>
</
SearchField
.
Group
>
{
option
.
description
?
(
</
SearchField
>
<
div
</
div
>
className=
"truncate text-xs text-muted"
{
loading
?
(
title=
{
option
.
description
}
<
div
className=
"grid min-h-24 place-items-center"
role=
"status"
><
Spinner
size=
"sm"
aria
-
label=
"搜索中"
/></
div
>
)
:
error
?
(
<
div
className=
"px-3 py-4 text-sm text-danger"
role=
"alert"
>
{
error
}
</
div
>
)
:
menuOptions
.
length
===
0
?
(
<
div
className=
"px-3 py-4 text-center text-sm text-muted"
role=
"status"
>
无匹配结果
</
div
>
)
:
(
<
ListBox
aria
-
label=
{
placeholder
}
className=
"rc-reference-select-options"
items=
{
menuOptions
}
selectedKeys=
{
new
Set
(
selectedIds
)
}
selectionMode=
{
multiple
?
'multiple'
:
'single'
}
onSelectionChange=
{
handleSelectionChange
}
>
>
{
option
.
description
}
{
(
option
)
=>
(
</
div
>
<
ListBox
.
Item
className=
"rc-reference-select-option"
id=
{
option
.
id
}
textValue=
{
option
.
label
}
>
)
:
null
}
<
div
className=
"min-w-0 flex-1 overflow-hidden"
>
</
div
>
<
Label
className=
"block truncate"
title=
{
option
.
label
}
>
{
option
.
label
}
</
Label
>
)
}
{
option
.
description
?
<
div
className=
"truncate text-xs text-muted"
title=
{
option
.
description
}
>
{
option
.
description
}
</
div
>
:
null
}
/>
</
div
>
<
ListBox
.
ItemIndicator
/>
</
ListBox
.
Item
>
)
}
</
ListBox
>
)
}
</
Popover
.
Dialog
>
</
Popover
.
Content
>
</
Popover
.
Root
>
);
);
}
}
src/components/record-workbench/index.jsx
View file @
f99189d9
...
@@ -10,10 +10,174 @@ import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
...
@@ -10,10 +10,174 @@ import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import
{
ActionBar
}
from
'@heroui-pro/react/action-bar'
;
import
{
ActionBar
}
from
'@heroui-pro/react/action-bar'
;
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
DataGrid
}
from
'@heroui-pro/react/data-grid'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Button
,
Disclosure
,
Dropdown
,
Input
,
Label
,
SearchField
,
Spinner
,
Surface
,
Tabs
,
Tooltip
,
buttonVariants
}
from
'@heroui/react'
;
import
{
Alert
,
Button
,
Disclosure
,
Dropdown
,
Input
,
Label
,
SearchField
,
Spinner
,
Surface
,
Tabs
,
ToggleButton
,
Tooltip
,
buttonVariants
}
from
'@heroui/react'
;
import
{
useLayoutEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
import
{
useEffect
,
useLayoutEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
function
getPinnedSide
(
column
)
{
const
side
=
column
?.
lockPosition
||
column
?.
pinned
;
return
side
===
'left'
||
side
===
'right'
?
side
:
null
;
}
function
buildColumnGroupMeta
(
columnGroups
=
[],
columns
=
[])
{
const
fieldLookup
=
columns
.
reduce
((
lookup
,
column
)
=>
{
const
field
=
String
(
column
?.
field
||
''
).
trim
();
if
(
!
field
)
{
return
lookup
;
}
lookup
.
set
(
field
,
field
);
lookup
.
set
(
field
.
replace
(
/^metadata
\.
/
,
''
),
field
);
return
lookup
;
},
new
Map
());
const
normalizedGroups
=
(
Array
.
isArray
(
columnGroups
)
?
columnGroups
:
[])
.
map
((
group
,
index
)
=>
{
const
id
=
group
?.
id
||
group
?.
name
||
`group-
${
index
}
`
;
const
configuredChildren
=
group
?.
children
||
group
?.
fields
||
group
?.
columns
;
const
children
=
Array
.
from
(
new
Set
(
(
Array
.
isArray
(
configuredChildren
)
?
configuredChildren
:
[])
.
map
((
child
)
=>
{
const
configuredField
=
typeof
child
===
'string'
?
child
:
child
?.
field
||
child
?.
name
||
child
?.
key
||
''
;
const
normalizedField
=
String
(
configuredField
).
trim
();
return
(
fieldLookup
.
get
(
normalizedField
)
||
fieldLookup
.
get
(
normalizedField
.
replace
(
/^metadata
\.
/
,
''
))
||
''
);
})
.
filter
(
Boolean
),
),
);
return
{
...
group
,
id
,
name
:
String
(
group
?.
label
||
''
).
trim
()
||
String
(
group
?.
title
||
''
).
trim
()
||
String
(
group
?.
name
||
''
).
trim
()
||
id
,
children
,
columnLabels
:
children
.
map
((
field
)
=>
{
const
column
=
columns
.
find
((
item
)
=>
item
.
field
===
field
);
return
String
(
column
?.
label
||
field
).
trim
();
}),
};
})
.
filter
((
group
)
=>
group
.
children
.
length
>
0
);
const
fieldToGroup
=
normalizedGroups
.
reduce
((
mapping
,
group
)
=>
{
group
.
children
.
forEach
((
field
)
=>
{
if
(
!
mapping
[
field
])
{
mapping
[
field
]
=
group
.
id
;
}
});
return
mapping
;
},
{});
const
groupById
=
normalizedGroups
.
reduce
((
mapping
,
group
)
=>
{
mapping
[
group
.
id
]
=
group
;
return
mapping
;
},
{});
return
{
groups
:
normalizedGroups
,
fieldToGroup
,
groupById
,
};
}
function
orderColumnsByGroups
(
columns
=
[],
fieldToGroup
=
{},
groupById
=
{})
{
const
columnsByField
=
new
Map
(
columns
.
map
((
column
)
=>
[
column
.
field
,
column
]));
const
emittedFields
=
new
Set
();
const
orderedColumns
=
[];
columns
.
forEach
((
column
)
=>
{
if
(
emittedFields
.
has
(
column
.
field
))
{
return
;
}
const
groupId
=
fieldToGroup
[
column
.
field
];
const
group
=
groupId
?
groupById
[
groupId
]
:
null
;
if
(
!
group
)
{
emittedFields
.
add
(
column
.
field
);
orderedColumns
.
push
(
column
);
return
;
}
const
pinnedSide
=
getPinnedSide
(
column
)
||
'center'
;
group
.
children
.
forEach
((
field
)
=>
{
const
groupedColumn
=
columnsByField
.
get
(
field
);
if
(
!
groupedColumn
||
emittedFields
.
has
(
field
)
||
fieldToGroup
[
field
]
!==
groupId
)
{
return
;
}
if
((
getPinnedSide
(
groupedColumn
)
||
'center'
)
!==
pinnedSide
)
{
return
;
}
emittedFields
.
add
(
field
);
orderedColumns
.
push
(
groupedColumn
);
});
});
return
orderedColumns
;
}
function
ColumnGroupControl
({
group
,
collapsed
,
onPreviewChange
,
onToggle
})
{
const
actionLabel
=
`
${
collapsed
?
'显示'
:
'隐藏'
}${
group
.
name
}
列`
;
const
columnSummary
=
group
.
columnLabels
.
join
(
'、'
);
return
(
<
Tooltip
closeDelay=
{
80
}
delay=
{
150
}
>
<
ToggleButton
size=
"sm"
isSelected=
{
collapsed
}
onChange=
{
onToggle
}
onBlur=
{
()
=>
onPreviewChange
?.(
null
)
}
onFocus=
{
()
=>
onPreviewChange
?.(
group
.
id
)
}
onHoverEnd=
{
()
=>
onPreviewChange
?.(
null
)
}
onHoverStart=
{
()
=>
onPreviewChange
?.(
group
.
id
)
}
aria
-
label=
{
actionLabel
}
>
<
span
>
{
group
.
name
}
</
span
>
<
span
aria
-
hidden=
"true"
className=
"text-xs opacity-70"
>
{
group
.
children
.
length
}
列
</
span
>
</
ToggleButton
>
<
Tooltip
.
Content
className=
"max-w-80"
placement=
"bottom"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-medium"
>
{
actionLabel
}
</
div
>
<
div
className=
"text-xs opacity-80"
>
包含:
{
columnSummary
}
</
div
>
</
div
>
</
Tooltip
.
Content
>
</
Tooltip
>
);
}
function
ColumnHeaderLabel
({
label
,
groupName
})
{
if
(
!
groupName
)
{
return
label
;
}
return
(
<
span
className=
"flex min-w-0 flex-col items-start gap-0.5 py-0.5"
>
<
span
aria
-
hidden=
"true"
className=
"text-[11px] font-normal leading-4 text-muted"
>
{
groupName
}
</
span
>
<
span
className=
"text-xs font-medium leading-4 text-foreground"
>
{
label
}
</
span
>
</
span
>
);
}
const
EMPTY_ARRAY
=
[];
const
EMPTY_ARRAY
=
[];
const
EMPTY_GROUPS
=
{};
const
ACTION_ICON_CLASS
=
'!grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4'
;
const
ACTION_ICON_CLASS
=
'!grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4'
;
function
getRecordId
(
record
)
{
function
getRecordId
(
record
)
{
...
@@ -180,6 +344,8 @@ export default function RecordWorkbench({
...
@@ -180,6 +344,8 @@ export default function RecordWorkbench({
title,
title,
listTitle,
listTitle,
columns = EMPTY_ARRAY,
columns = EMPTY_ARRAY,
columnGroups = EMPTY_ARRAY,
columnGroupScope,
records = EMPTY_ARRAY,
records = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY,
onSelectionChange,
onSelectionChange,
...
@@ -195,6 +361,36 @@ export default function RecordWorkbench({
...
@@ -195,6 +361,36 @@ export default function RecordWorkbench({
const [detailMode, setDetailMode] = useState('view');
const [detailMode, setDetailMode] = useState('view');
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const gridWrapRef = useRef(null);
const gridWrapRef = useRef(null);
const [groupVisibility, setGroupVisibility] = useState({ scope: null, collapsed: {} });
const [previewColumnGroupId, setPreviewColumnGroupId] = useState(null);
const [columnGroupAnnouncement, setColumnGroupAnnouncement] = useState('');
const pendingScrollRef = useRef(null);
const groupScope = columnGroupScope || activeViewName || title;
const collapsedColumnGroups = groupVisibility.scope === groupScope ? groupVisibility.collapsed : EMPTY_GROUPS;
useEffect(() => {
setGroupVisibility({ scope: groupScope, collapsed: {} });
setPreviewColumnGroupId(null);
setColumnGroupAnnouncement('');
}, [groupScope]);
const columnGroupMeta = useMemo(() => buildColumnGroupMeta(columnGroups, columns), [columnGroups, columns]);
const visibleColumns = useMemo(() => orderColumnsByGroups(
columns.filter((column) => !collapsedColumnGroups[columnGroupMeta.fieldToGroup[column.field]]),
columnGroupMeta.fieldToGroup,
columnGroupMeta.groupById,
), [columns, columnGroupMeta, collapsedColumnGroups]);
const toggleColumnGroup = (groupId) => {
const scrollNode = gridWrapRef.current?.querySelector('.table__resizable-container, .data-table-scroll');
if (scrollNode) pendingScrollRef.current = scrollNode.scrollLeft;
const willShow = Boolean(collapsedColumnGroups[groupId]);
setGroupVisibility({ scope: groupScope, collapsed: { ...collapsedColumnGroups, [groupId]: !willShow } });
const group = columnGroupMeta.groupById[groupId];
setColumnGroupAnnouncement(`
$
{
group
.
name
}
$
{
willShow
?
'已显示'
:
'已隐藏'
}
,
$
{
group
.
children
.
length
}
列
`);
};
useLayoutEffect(() => {
const scrollNode = gridWrapRef.current?.querySelector('.table__resizable-container, .data-table-scroll');
if (scrollNode && pendingScrollRef.current !== null) scrollNode.scrollLeft = pendingScrollRef.current;
pendingScrollRef.current = null;
}, [groupVisibility]);
const selectedRecords = useMemo(() => records.filter((record) => selectedIds.includes(getRecordId(record))), [records, selectedIds]);
const selectedRecords = useMemo(() => records.filter((record) => selectedIds.includes(getRecordId(record))), [records, selectedIds]);
const openDetail = (record) => {
const openDetail = (record) => {
const recordName = detailPanel?.getRecordName?.(record) || record?.name || getRecordId(record);
const recordName = detailPanel?.getRecordName?.(record) || record?.name || getRecordId(record);
...
@@ -218,12 +414,12 @@ export default function RecordWorkbench({
...
@@ -218,12 +414,12 @@ export default function RecordWorkbench({
);
);
const hasDetailView = Boolean(detailPanel?.render);
const hasDetailView = Boolean(detailPanel?.render);
const totalRowActionCount = rowActions.length + (hasDetailView ? 1 : 0);
const totalRowActionCount = rowActions.length + (hasDetailView ? 1 : 0);
const columnWidths = useMemo(() =>
c
olumns.map((column, index) => {
const columnWidths = useMemo(() =>
visibleC
olumns.map((column, index) => {
const configuredWidth = Number(column.width || 180);
const configuredWidth = Number(column.width || 180);
return index === 0 && hasDetailView
return index === 0 && hasDetailView
? Math.max(Math.min(Math.max(configuredWidth, 220), 320), estimatePrimaryColumnWidth(records, column))
? Math.max(Math.min(Math.max(configuredWidth, 220), 320), estimatePrimaryColumnWidth(records, column))
: Math.min(Math.max(configuredWidth, 150), 360);
: Math.min(Math.max(configuredWidth, 150), 360);
}), [
c
olumns, hasDetailView, records]);
}), [
visibleC
olumns, hasDetailView, records]);
const actionColumnWidth = resolveActionColumnWidth(totalRowActionCount);
const actionColumnWidth = resolveActionColumnWidth(totalRowActionCount);
const gridMinimumWidth = columnWidths.reduce((total, width) => total + width, 0) + actionColumnWidth + (onSelectionChange ? 44 : 0);
const gridMinimumWidth = columnWidths.reduce((total, width) => total + width, 0) + actionColumnWidth + (onSelectionChange ? 44 : 0);
...
@@ -238,11 +434,12 @@ export default function RecordWorkbench({
...
@@ -238,11 +434,12 @@ export default function RecordWorkbench({
}, [gridMinimumWidth]);
}, [gridMinimumWidth]);
const gridColumns = useMemo(() => {
const gridColumns = useMemo(() => {
const mapped =
c
olumns.map((column, index) => {
const mapped =
visibleC
olumns.map((column, index) => {
const width = columnWidths[index];
const width = columnWidths[index];
return {
return {
id: String(column.field),
id: String(column.field),
header: column.label,
header: <ColumnHeaderLabel label={column.label} groupName={columnGroupMeta.groupById[columnGroupMeta.fieldToGroup[column.field]]?.name} />,
headerClassName: columnGroupMeta.fieldToGroup[column.field] === previewColumnGroupId ? 'bg-accent-soft text-accent-soft-foreground' : undefined,
isRowHeader: index === 0,
isRowHeader: index === 0,
allowsSorting: true,
allowsSorting: true,
sortFn: (left, right) => {
sortFn: (left, right) => {
...
@@ -304,7 +501,7 @@ export default function RecordWorkbench({
...
@@ -304,7 +501,7 @@ export default function RecordWorkbench({
});
});
}
}
return mapped;
return mapped;
}, [actionColumnWidth, baseContext, columnWidths,
columns
, hasDetailView, hasHorizontalOverflow, renderCell, rowActions, totalRowActionCount]);
}, [actionColumnWidth, baseContext, columnWidths,
visibleColumns, columnGroupMeta, previewColumnGroupId
, hasDetailView, hasHorizontalOverflow, renderCell, rowActions, totalRowActionCount]);
const handleSelectionChange = (selection) => {
const handleSelectionChange = (selection) => {
if (selection === 'all') onSelectionChange?.(records.map(getRecordId));
if (selection === 'all') onSelectionChange?.(records.map(getRecordId));
...
@@ -469,6 +666,15 @@ export default function RecordWorkbench({
...
@@ -469,6 +666,15 @@ export default function RecordWorkbench({
) : null}
) : null}
</header>
</header>
) : null}
) : null}
{!accessDenied && columnGroupMeta.groups.length > 0 ? (
<div className="flex flex-wrap items-center gap-2" aria-label="列分组显示控制">
{columnGroupMeta.groups.map((group) => (
<ColumnGroupControl key={group.id} group={group} collapsed={Boolean(collapsedColumnGroups[group.id])}
onPreviewChange={setPreviewColumnGroupId} onToggle={() => toggleColumnGroup(group.id)} />
))}
<span className="sr-only" aria-live="polite" aria-atomic="true">{columnGroupAnnouncement}</span>
</div>
) : null}
{states.warning ? <Alert status="warning"><Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content></Alert> : null}
{states.warning ? <Alert status="warning"><Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-list-data-stack rc-data-table-sticky relative min-w-0" ref={gridWrapRef}>
<div className="rc-list-data-stack rc-data-table-sticky relative min-w-0" ref={gridWrapRef}>
{!states.loading && states.error ? (
{!states.loading && states.error ? (
...
...
src/design-system/shell.css
View file @
f99189d9
...
@@ -1716,3 +1716,48 @@
...
@@ -1716,3 +1716,48 @@
outline
:
2px
solid
var
(
--rc-sidebar-focus
);
outline
:
2px
solid
var
(
--rc-sidebar-focus
);
outline-offset
:
2px
;
outline-offset
:
2px
;
}
}
.rc-reference-select-trigger
{
display
:
flex
;
justify-content
:
space-between
;
width
:
100%
;
min-width
:
0
;
height
:
40px
;
padding
:
0
12px
;
border
:
1px
solid
var
(
--rc-field-border
,
var
(
--rc-border-default
));
border-radius
:
8px
;
background
:
var
(
--rc-field-background
);
color
:
var
(
--rc-text-primary
);
font-weight
:
400
;
}
.rc-reference-select-trigger__value
{
min-width
:
0
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.rc-reference-select-trigger__indicator
{
display
:
inline-grid
;
place-items
:
center
;
flex
:
0
0
16px
;
color
:
var
(
--rc-text-muted
);
}
.rc-reference-select-popover
{
width
:
var
(
--trigger-width
);
max-width
:
min
(
24rem
,
calc
(
100vw
-
24px
));
border
:
1px
solid
var
(
--rc-border-default
);
border-radius
:
12px
;
background
:
var
(
--rc-bg-surface
);
}
.rc-reference-select-options
{
max-height
:
288px
;
overflow-y
:
auto
;
}
.rc-reference-select-option
[
data-selected
]
{
background
:
var
(
--rc-brand-primary-soft
,
var
(
--accent-soft
));
color
:
var
(
--rc-brand-primary
);
}
src/pages/integrity_practice_management/SubjectWorkbench.jsx
View file @
f99189d9
...
@@ -10,6 +10,7 @@ import { useLocation } from 'umi';
...
@@ -10,6 +10,7 @@ import { useLocation } from 'umi';
import
UserAssignmentDialog
from
'./UserAssignmentDialog'
;
import
UserAssignmentDialog
from
'./UserAssignmentDialog'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
FormDatePicker
from
'@/components/FormDatePicker'
;
import
FormDatePicker
from
'@/components/FormDatePicker'
;
import
ReferenceSearchSelect
from
'@/components/ReferenceSearchSelect'
;
import
{
import
{
getSubjectNameFromPath
,
getSubjectNameFromPath
,
INTEGRITY_PRACTICE_SUBJECT_CONFIG
,
INTEGRITY_PRACTICE_SUBJECT_CONFIG
,
...
@@ -27,7 +28,7 @@ import {
...
@@ -27,7 +28,7 @@ import {
buildIntegrityCreatePayload
,
buildIntegrityCreatePayload
,
}
from
'./integrityPracticeWorkbenchService'
;
}
from
'./integrityPracticeWorkbenchService'
;
import
{
createRecord
}
from
'@/services/DataService'
;
import
{
createRecord
}
from
'@/services/DataService'
;
import
{
getViewRestrictionDefaults
,
getSelectOptions
,
emptyValueForField
,
toArray
}
from
'@/utils/subjectView'
;
import
{
getViewRestrictionDefaults
,
getSelectOptions
,
getReferenceSubjectName
,
isReferenceFieldType
,
emptyValueForField
,
toArray
}
from
'@/utils/subjectView'
;
const
EMPTY_ARRAY
=
[];
const
EMPTY_ARRAY
=
[];
const
MUTATING_ACTION_PATTERN
=
/delete|archive|create|push/i
;
const
MUTATING_ACTION_PATTERN
=
/delete|archive|create|push/i
;
...
@@ -347,6 +348,8 @@ export default function SubjectWorkbench() {
...
@@ -347,6 +348,8 @@ export default function SubjectWorkbench() {
:
'字段、列顺序和视图均来自当前 Subject 配置。'
:
'字段、列顺序和视图均来自当前 Subject 配置。'
}
}
columns=
{
columns
}
columns=
{
columns
}
columnGroups=
{
activeView
?.
settings
?.
groups
||
EMPTY_ARRAY
}
columnGroupScope=
{
`${subjectName}:${activeView?.name || ''}`
}
records=
{
records
}
records=
{
records
}
selectedIds=
{
selectedIds
}
selectedIds=
{
selectedIds
}
onSelectionChange=
{
hasAccessibleView
?
setSelectedIds
:
undefined
}
onSelectionChange=
{
hasAccessibleView
?
setSelectedIds
:
undefined
}
...
@@ -446,7 +449,18 @@ export default function SubjectWorkbench() {
...
@@ -446,7 +449,18 @@ export default function SubjectWorkbench() {
key=
{
field
.
field
}
key=
{
field
.
field
}
>
>
<
Label
>
{
fieldLabel
}
</
Label
>
<
Label
>
{
fieldLabel
}
</
Label
>
{
options
.
length
?
(
{
isReferenceFieldType
(
fieldType
)
?
(
<
ReferenceSearchSelect
subject=
{
getReferenceSubjectName
(
field
.
fieldDef
)
}
value=
{
value
}
multiple=
{
isMultiple
}
disabled=
{
isFieldDisabled
||
actionLoading
}
placeholder=
{
`搜索并选择${fieldLabel}`
}
optionsAnchor=
"bottom start"
selectedSummaryMode=
{
isMultiple
?
'count'
:
'labels'
}
onChange=
{
(
nextValue
)
=>
setCreateValues
((
current
)
=>
({
...
current
,
[
name
]:
nextValue
}))
}
/>
)
:
options
.
length
?
(
<
XinyuanAutocomplete
<
XinyuanAutocomplete
aria
-
label=
{
fieldLabel
}
aria
-
label=
{
fieldLabel
}
isClearable
isClearable
...
...
src/pages/integrity_practice_management/integrity-practice-management.business-flow.md
View file @
f99189d9
...
@@ -33,6 +33,7 @@
...
@@ -33,6 +33,7 @@
9.
当前 Detail 有有效
`settings.auth`
或
`settings.disableColumns`
时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;
`disableColumns`
对管理员也始终只读,普通用户在选定权限来源中缺少字段
`auth`
时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid
`viewAuth`
只控制 Tab 可见性,不隐藏详情字段。
9.
当前 Detail 有有效
`settings.auth`
或
`settings.disableColumns`
时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;
`disableColumns`
对管理员也始终只读,普通用户在选定权限来源中缺少字段
`auth`
时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid
`viewAuth`
只控制 Tab 可见性,不隐藏详情字段。
10.
编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为
`_id`
或
`_id[]`
,不改变保存时机和
`metadata`
协议。
10.
编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为
`_id`
或
`_id[]`
,不改变保存时机和
`metadata`
协议。
11.
编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
11.
编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
12.
自查底稿填写表的“模板”和“项目结果”等引用字段使用独立受控的 HeroUI Popover + ListBox,点击即打开,候选尚未加载或当前值为空也可展开。打开后显示加载状态并查询目标对象,支持远程搜索、单选/多选与清空;失败时显示错误并可重新搜索或关闭后重开。修改只更新弹窗草稿,取消不写入,保存仍通过
`PUT /record/:name`
提交引用
`_id`
/
`_id[]`
。
异常处理:
异常处理:
...
@@ -40,8 +41,18 @@
...
@@ -40,8 +41,18 @@
-
没有可访问
`grid`
视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
-
没有可访问
`grid`
视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
-
配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
-
配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
### 2.0 字段分组表头与显示控制
-
各业务列表读取当前 Grid View 的
`settings.groups`
;兼容
`children`
/
`fields`
/
`columns`
字段列表及带或不带
`metadata.`
的字段路径,仅显示当前视图中有效的列,空分组忽略。
-
同组字段按配置顺序相邻展示,并保留固定列区域。表头采用与廉洁风险防控一致的上下两行:上方为弱化组名,下方为字段名称;未分组字段保留普通表头。
-
表格上方提供分组开关和列数,默认全部展开。点击可隐藏整组字段,再次点击恢复;悬停或键盘聚焦显示字段清单,并突出对应表头。隐藏后保留分组开关,可随时恢复。
-
分组切换只改变当前表格展示,保留勾选、筛选、分页和记录数据,并按可见列宽重新计算横向滚动与固定列;切换 Subject 或视图后恢复默认展开。分组字段全部隐藏时保留选择及操作列。
-
本交互不新增请求、不修改 Subject 配置,也不改变
`metadata`
、审批或导出协议。
### 2.1 新建记录与 View 限制
### 2.1 新建记录与 View 限制
-
自查底稿模板列表点击“新增记录”后,“填写项目”(
`entry_item`
)按字段配置远程查询
`self_check_the_working_draft`
,使用共享引用选择器搜索、多选、取消选择或清空,草稿和最终
`metadata.entry_item`
只保存
`_id[]`
。其它引用字段同样读取目标 Subject 和
`multiple`
配置,单值保存
`_id`
,选择后关闭弹层;多值可连续选择。
-
引用候选查询通过
`POST /record/query`
,搜索期间显示加载状态,无结果或失败时显示对应提示,可重新搜索。选择仅更新新建草稿,点击弹窗保存才调用创建接口;视图限制字段及提交期间禁用选择器。
-
新建记录默认继承当前 Grid View 的
`settings.restrictions`
。
`$eq`
或单值
`$in`
条件会将唯一值写入
`metadata`
,对应控件禁用;即使字段未列入弹窗,也会随请求写入。
-
新建记录默认继承当前 Grid View 的
`settings.restrictions`
。
`$eq`
或单值
`$in`
条件会将唯一值写入
`metadata`
,对应控件禁用;即使字段未列入弹窗,也会随请求写入。
-
多值集合、否定条件或同一字段互相冲突时无法确定唯一值,禁止创建并提示“请先调整视图配置”。
-
多值集合、否定条件或同一字段互相冲突时无法确定唯一值,禁止创建并提示“请先调整视图配置”。
-
新建请求使用
`POST /record`
,所有业务字段放在
`metadata`
;编辑已有记录只提交用户修改字段,不使用 Grid View 限制覆盖原值。
-
新建请求使用
`POST /record`
,所有业务字段放在
`metadata`
;编辑已有记录只提交用户修改字段,不使用 Grid View 限制覆盖原值。
...
@@ -50,6 +61,8 @@
...
@@ -50,6 +61,8 @@
入口为 streams-admin 的应用布局配置或 Subject 的 Grid 视图配置,作用于本模块七个业务列表的共享 RecordWorkbench 表格中的引用、选择、子对象及 form 字段。页面加载应用及视图配置后按列渲染,切换视图时使用该视图的配置。
入口为 streams-admin 的应用布局配置或 Subject 的 Grid 视图配置,作用于本模块七个业务列表的共享 RecordWorkbench 表格中的引用、选择、子对象及 form 字段。页面加载应用及视图配置后按列渲染,切换视图时使用该视图的配置。
-
视图中配置的
`metadata.*`
业务列必须存在于当前 Subject 字段定义中;残留的无效列不展示,也不作为默认筛选项。Record 根级标题、创建时间等系统列保留原规则。“附件1:廉洁从业自查底稿”对应
`self_inspection_submission_form`
,不包含
`archive_status`
,因此不显示“归档状态”列;自查底稿填写表中真实定义的归档状态字段继续显示。
-
应用默认值读取
`layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`
,兼容 streams-admin 的
`displayConfig.maxTagCount`
。
-
应用默认值读取
`layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`
,兼容 streams-admin 的
`displayConfig.maxTagCount`
。
-
视图默认值读取
`settings.displayConfig`
,同名属性覆盖应用默认值;
`settings.columns[]`
中的单列
`displayConfig`
再覆盖视图默认值,列上的
`displayMode`
、
`maxTagCount`
、
`titleField`
优先级最高。
-
视图默认值读取
`settings.displayConfig`
,同名属性覆盖应用默认值;
`settings.columns[]`
中的单列
`displayConfig`
再覆盖视图默认值,列上的
`displayMode`
、
`maxTagCount`
、
`titleField`
优先级最高。
-
`displayMode: "labels"`
显示已选选项或关联标题;选项文案来自字段
`settings.options`
,仅展示当前记录实际选中的值。
-
`displayMode: "labels"`
显示已选选项或关联标题;选项文案来自字段
`settings.options`
,仅展示当前记录实际选中的值。
...
...
src/utils/subjectView.js
View file @
f99189d9
...
@@ -286,6 +286,7 @@ export function buildViewColumns(view, subject, { includeHidden = false, display
...
@@ -286,6 +286,7 @@ export function buildViewColumns(view, subject, { includeHidden = false, display
return
configuredColumns
return
configuredColumns
.
filter
((
column
)
=>
.
filter
((
column
)
=>
(
includeHidden
||
!
column
.
hide
)
&&
(
!
hideTitleColumn
||
column
.
field
!==
'title'
))
(
includeHidden
||
!
column
.
hide
)
&&
(
!
hideTitleColumn
||
column
.
field
!==
'title'
))
.
filter
((
column
)
=>
!
column
.
field
?.
startsWith
(
'metadata.'
)
||
getFieldDefinition
(
column
.
field
,
subject
))
.
map
((
column
)
=>
{
.
map
((
column
)
=>
{
const
fieldDef
=
getFieldDefinition
(
column
.
field
,
subject
);
const
fieldDef
=
getFieldDefinition
(
column
.
field
,
subject
);
const
label
=
getFieldLabel
(
column
.
field
,
fieldDef
,
column
.
label
);
const
label
=
getFieldLabel
(
column
.
field
,
fieldDef
,
column
.
label
);
...
...
tests/tableFieldDisplay.test.mjs
View file @
f99189d9
...
@@ -35,6 +35,21 @@ const { getTableFieldDisplay, getTableFieldDisplayConfig, formatTableFieldValue
...
@@ -35,6 +35,21 @@ const { getTableFieldDisplay, getTableFieldDisplayConfig, formatTableFieldValue
const
{
buildViewColumns
}
=
loadModule
(
'utils/subjectView.js'
);
const
{
buildViewColumns
}
=
loadModule
(
'utils/subjectView.js'
);
const
column
=
(
type
,
config
=
{})
=>
({
field
:
'metadata.values'
,
fieldDef
:
{
type
},
...
config
});
const
column
=
(
type
,
config
=
{})
=>
({
field
:
'metadata.values'
,
fieldDef
:
{
type
},
...
config
});
test
(
'view columns omit stale metadata fields while retaining real archive and system fields'
,
()
=>
{
const
view
=
{
settings
:
{
columns
:
[
{
field
:
'title'
},
{
field
:
'metadata.inspection_item'
},
{
field
:
'metadata.archive_status'
},
{
field
:
'created_at'
},
]
}
};
const
subject
=
{
fields
:
[{
name
:
'inspection_item'
,
type
:
'select'
}]
};
assert
.
deepEqual
(
buildViewColumns
(
view
,
subject
).
map
(({
field
})
=>
field
),
[
'title'
,
'metadata.inspection_item'
,
'created_at'
,
]);
subject
.
fields
.
push
({
name
:
'archive_status'
,
type
:
'select'
});
assert
.
equal
(
buildViewColumns
(
view
,
subject
)[
2
].
field
,
'metadata.archive_status'
);
});
test
(
'count mode counts selected values, including zero, and excludes empty entries'
,
()
=>
{
test
(
'count mode counts selected values, including zero, and excludes empty entries'
,
()
=>
{
const
config
=
column
(
'select'
,
{
displayMode
:
'count'
});
const
config
=
column
(
'select'
,
{
displayMode
:
'count'
});
assert
.
equal
(
formatTableFieldValue
([
0
,
'选中'
,
null
,
undefined
,
''
,
' '
],
config
),
'2'
);
assert
.
equal
(
formatTableFieldValue
([
0
,
'选中'
,
null
,
undefined
,
''
,
' '
],
config
),
'2'
);
...
...
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