Commit 9b9085cc authored by David Yang's avatar David Yang

update: ui

parent 2d3757b2
...@@ -12,13 +12,15 @@ const ROUTE_PATHS = { ...@@ -12,13 +12,15 @@ const ROUTE_PATHS = {
problemRectification: '/problem_rectification', problemRectification: '/problem_rectification',
issueRectificationArchive: '/issue_rectification_archive', issueRectificationArchive: '/issue_rectification_archive',
problemRectificationForm: '/problem_rectification_form', problemRectificationForm: '/problem_rectification_form',
problemRectificationFlowTable: '/problem_rectification_flow_table',
} as const; } as const;
const APP_BASE_PATH = '/problem_rectification_new/';
export default defineConfig({ export default defineConfig({
layout: false, layout: false,
fastRefresh: true, fastRefresh: true,
base: '/problem_rectification_new/', base: APP_BASE_PATH,
publicPath: '/problem_rectification_new/', publicPath: APP_BASE_PATH,
alias: { alias: {
react: resolvePackageRoot('react'), react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'), 'react-dom': resolvePackageRoot('react-dom'),
...@@ -58,6 +60,10 @@ export default defineConfig({ ...@@ -58,6 +60,10 @@ export default defineConfig({
path: ROUTE_PATHS.problemRectificationForm, path: ROUTE_PATHS.problemRectificationForm,
component: './problem_rectification/form/index', component: './problem_rectification/form/index',
}, },
{
path: ROUTE_PATHS.problemRectificationFlowTable,
component: './problem_rectification/flow-table/index',
},
], ],
}, },
], ],
...@@ -88,5 +94,6 @@ export default defineConfig({ ...@@ -88,5 +94,6 @@ export default defineConfig({
define: { define: {
STREAMS_API_URL: '/api', STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser', STREAMS_PARSER_URL: '/parser',
PROBLEM_RECTIFICATION_APP_BASE_PATH: APP_BASE_PATH,
}, },
}); });
...@@ -16,6 +16,7 @@ export function Table({ ...@@ -16,6 +16,7 @@ export function Table({
dense = false, dense = false,
grid = false, grid = false,
striped = false, striped = false,
scrollable = true,
className, className,
children, children,
...props ...props
...@@ -27,7 +28,9 @@ export function Table({ ...@@ -27,7 +28,9 @@ export function Table({
{...props} {...props}
className={clsx( className={clsx(
className, className,
'-mx-(--gutter) overflow-x-auto whitespace-nowrap', '-mx-(--gutter)',
scrollable && 'overflow-x-auto',
'whitespace-nowrap',
)} )}
> >
<div <div
......
...@@ -12,16 +12,18 @@ import { ...@@ -12,16 +12,18 @@ import {
} from '@/catalyst-ui-kit/javascript/table'; } from '@/catalyst-ui-kit/javascript/table';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import { import {
ChevronRightIcon,
DocumentDuplicateIcon, DocumentDuplicateIcon,
EyeIcon, EyeIcon,
TrashIcon, TrashIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react'; import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
const SELECTION_COLUMN_WIDTH = 56; const SELECTION_COLUMN_WIDTH = 56;
const DEFAULT_ACTION_COLUMN_WIDTH = 168; const DEFAULT_ACTION_COLUMN_WIDTH = 168;
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const DEFAULT_ROW_ACTION_ICONS = { const DEFAULT_ROW_ACTION_ICONS = {
clone: DocumentDuplicateIcon, clone: DocumentDuplicateIcon,
delete: TrashIcon, delete: TrashIcon,
...@@ -275,6 +277,33 @@ function defaultRenderCell(record, column) { ...@@ -275,6 +277,33 @@ function defaultRenderCell(record, column) {
); );
} }
function buildPaginationPageItems(currentPage, totalPages) {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index);
}
const pageSet = new Set([
0,
totalPages - 1,
currentPage - 1,
currentPage,
currentPage + 1,
]);
const pages = Array.from(pageSet)
.filter((page) => page >= 0 && page < totalPages)
.sort((left, right) => left - right);
return pages.reduce((items, page, index) => {
const previousPage = pages[index - 1];
if (index > 0 && page - previousPage > 1) {
items.push(`gap-${previousPage}-${page}`);
}
items.push(page);
return items;
}, []);
}
function ColumnGroupButton({ group, collapsed, onToggle }) { function ColumnGroupButton({ group, collapsed, onToggle }) {
return ( return (
<button <button
...@@ -327,6 +356,93 @@ function RowActionButton({ action, disabled, onClick }) { ...@@ -327,6 +356,93 @@ function RowActionButton({ action, disabled, onClick }) {
); );
} }
function WorkbenchPagination({ pagination }) {
if (!pagination) {
return null;
}
const pageSizeOptions = pagination.pageSizeOptions || DEFAULT_PAGE_SIZE_OPTIONS;
const pageSize = Number(pagination.pageSize || pageSizeOptions[0] || 20);
const totalRecords = Math.max(0, Number(pagination.totalRecords || 0));
const totalPages = Math.max(
1,
Number(pagination.totalPages || Math.ceil(totalRecords / pageSize) || 1),
);
const currentPage = Math.min(
Math.max(0, Number(pagination.page || 0)),
totalPages - 1,
);
const startRecord = totalRecords === 0 ? 0 : currentPage * pageSize + 1;
const endRecord = Math.min(totalRecords, (currentPage + 1) * pageSize);
const pageItems = buildPaginationPageItems(currentPage, totalPages);
return (
<div className="flex flex-col gap-3 border-t border-zinc-100 bg-zinc-50/70 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>
共 {totalRecords} 条,显示 {startRecord}-{endRecord}
</span>
<label className="flex items-center gap-2">
<span>每页</span>
<Select
className="w-24"
value={String(pageSize)}
onChange={(event) => pagination.onPageSizeChange?.(Number(event.target.value))}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
</label>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button
plain
disabled={currentPage <= 0}
onClick={() => pagination.onPageChange?.(currentPage - 1)}
>
上一页
</Button>
{pageItems.map((item) =>
typeof item === 'number' ? (
<Button
key={item}
plain
aria-current={item === currentPage ? 'page' : undefined}
className={clsx(
'min-w-9',
item === currentPage && 'bg-zinc-950/5 text-slate-950',
)}
onClick={() => pagination.onPageChange?.(item)}
>
{item + 1}
</Button>
) : (
<span
key={item}
className="flex h-9 min-w-9 items-center justify-center text-sm font-semibold text-slate-400"
>
...
</span>
),
)}
<Button
plain
disabled={currentPage >= totalPages - 1}
onClick={() => pagination.onPageChange?.(currentPage + 1)}
>
下一页
</Button>
</div>
</div>
);
}
export default function RecordWorkbench({ export default function RecordWorkbench({
summaryItems = [], summaryItems = [],
views = [], views = [],
...@@ -349,17 +465,26 @@ export default function RecordWorkbench({ ...@@ -349,17 +465,26 @@ export default function RecordWorkbench({
detailPanel = null, detailPanel = null,
states = {}, states = {},
renderCell, renderCell,
renderExpandedRow,
isRowExpandable,
getRecordId = defaultGetRecordId, getRecordId = defaultGetRecordId,
getRecordLabel = defaultGetRecordLabel, getRecordLabel = defaultGetRecordLabel,
selectable = true, selectable = true,
actionColumnWidth = DEFAULT_ACTION_COLUMN_WIDTH, actionColumnWidth = DEFAULT_ACTION_COLUMN_WIDTH,
pagination = null,
stickyTable = false,
showExpandColumn = true,
}) { }) {
const tableScrollRef = useRef(null);
const pendingTableScrollRef = useRef(null);
const [drawerRecordName, setDrawerRecordName] = useState(''); const [drawerRecordName, setDrawerRecordName] = useState('');
const [drawerDirty, setDrawerDirty] = useState(false); const [drawerDirty, setDrawerDirty] = useState(false);
const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({}); const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({});
const [expandedRowIds, setExpandedRowIds] = useState({});
const loading = Boolean(states?.loading); const loading = Boolean(states?.loading);
const error = states?.error || ''; const error = states?.error || '';
const totalRecords = records.length; const pageRecordCount = records.length;
const totalRecords = pagination?.totalRecords ?? records.length;
const columnGroupMeta = useMemo( const columnGroupMeta = useMemo(
() => buildColumnGroupMeta(columnGroups, columns), () => buildColumnGroupMeta(columnGroups, columns),
[columnGroups, columns], [columnGroups, columns],
...@@ -385,6 +510,14 @@ export default function RecordWorkbench({ ...@@ -385,6 +510,14 @@ export default function RecordWorkbench({
() => records.filter((record) => selectedIds.includes(getRecordId(record))), () => records.filter((record) => selectedIds.includes(getRecordId(record))),
[records, selectedIds, getRecordId], [records, selectedIds, getRecordId],
); );
const currentPageRecordIds = useMemo(
() => records.map((record) => getRecordId(record)).filter(Boolean),
[records, getRecordId],
);
const selectedCurrentPageIds = useMemo(
() => currentPageRecordIds.filter((id) => selectedIds.includes(id)),
[currentPageRecordIds, selectedIds],
);
const stickyColumns = useMemo( const stickyColumns = useMemo(
() => buildStickyColumnMeta(orderedVisibleColumns, selectable, rowActions.length > 0, actionColumnWidth), () => buildStickyColumnMeta(orderedVisibleColumns, selectable, rowActions.length > 0, actionColumnWidth),
...@@ -401,6 +534,13 @@ export default function RecordWorkbench({ ...@@ -401,6 +534,13 @@ export default function RecordWorkbench({
); );
const hasColumnGroups = columnGroupMeta.groups.length > 0; const hasColumnGroups = columnGroupMeta.groups.length > 0;
const showColumnGroupHeader = columnGroupSegments.some((segment) => segment.group); const showColumnGroupHeader = columnGroupSegments.some((segment) => segment.group);
const hasExpandableRows = typeof renderExpandedRow === 'function';
const hasExpandColumn = hasExpandableRows && showExpandColumn;
const tableColSpan =
stickyColumns.orderedColumns.length +
(selectable ? 1 : 0) +
(hasExpandColumn ? 1 : 0) +
(rowActions.length > 0 ? 1 : 0);
useEffect(() => { useEffect(() => {
const validGroupIds = new Set(columnGroupMeta.groups.map((group) => group.id)); const validGroupIds = new Set(columnGroupMeta.groups.map((group) => group.id));
...@@ -417,13 +557,46 @@ export default function RecordWorkbench({ ...@@ -417,13 +557,46 @@ export default function RecordWorkbench({
}); });
}, [columnGroupMeta.groups]); }, [columnGroupMeta.groups]);
const captureTableScroll = () => {
const scrollNode = tableScrollRef.current;
if (!scrollNode) {
return;
}
pendingTableScrollRef.current = {
top: scrollNode.scrollTop,
left: scrollNode.scrollLeft,
};
};
useLayoutEffect(() => {
const scrollPosition = pendingTableScrollRef.current;
const scrollNode = tableScrollRef.current;
if (!scrollPosition || !scrollNode) {
return;
}
scrollNode.scrollTop = scrollPosition.top;
scrollNode.scrollLeft = scrollPosition.left;
pendingTableScrollRef.current = null;
}, [collapsedColumnGroups]);
const toggleColumnGroup = (groupId) => { const toggleColumnGroup = (groupId) => {
captureTableScroll();
setCollapsedColumnGroups((current) => ({ setCollapsedColumnGroups((current) => ({
...current, ...current,
[groupId]: !current[groupId], [groupId]: !current[groupId],
})); }));
}; };
const toggleExpandedRow = (recordId) => {
setExpandedRowIds((current) => ({
...current,
[recordId]: !current[recordId],
}));
};
const openDetail = (recordOrName) => { const openDetail = (recordOrName) => {
const recordName = const recordName =
typeof recordOrName === 'string' typeof recordOrName === 'string'
...@@ -472,6 +645,20 @@ export default function RecordWorkbench({ ...@@ -472,6 +645,20 @@ export default function RecordWorkbench({
: filterSchema.length > 1 : filterSchema.length > 1
? 'xl:grid-cols-4' ? 'xl:grid-cols-4'
: 'xl:grid-cols-[1fr_auto]'; : 'xl:grid-cols-[1fr_auto]';
const tableSectionClass = clsx(
'overflow-hidden rounded-[32px] border border-white/80 bg-white/96 shadow-[0_18px_48px_rgba(15,23,42,0.08)] ring-1 ring-zinc-950/5',
stickyTable && 'sticky top-0 z-10 flex h-[calc(100dvh-6rem)] min-h-[32rem] flex-col',
);
const fixedTablePanelClass = stickyTable ? 'shrink-0' : '';
const tableContentClass = stickyTable
? 'min-h-0 flex-1 overflow-auto overscroll-contain bg-white px-3 pt-0 [scrollbar-gutter:stable]'
: 'px-3 pb-3 pt-2';
const tableHeadClass = stickyTable
? 'sticky top-0 z-40 bg-zinc-50 shadow-[0_10px_24px_rgba(15,23,42,0.08)]'
: undefined;
const stateContentClass = stickyTable
? 'min-h-0 flex-1 overflow-auto px-6 py-6'
: 'px-6 py-6';
return ( return (
<div className="space-y-6 pb-4"> <div className="space-y-6 pb-4">
...@@ -569,8 +756,8 @@ export default function RecordWorkbench({ ...@@ -569,8 +756,8 @@ export default function RecordWorkbench({
</section> </section>
) : null} ) : null}
<section className="overflow-hidden rounded-[32px] border border-white/80 bg-white/96 shadow-[0_18px_48px_rgba(15,23,42,0.08)] ring-1 ring-zinc-950/5"> <section className={tableSectionClass}>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5"> <div className={clsx('flex flex-wrap items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5', fixedTablePanelClass)}>
<div> <div>
<div className="text-lg font-semibold text-slate-900">{title}</div> <div className="text-lg font-semibold text-slate-900">{title}</div>
<div className="mt-1 text-sm text-slate-500"> <div className="mt-1 text-sm text-slate-500">
...@@ -600,7 +787,7 @@ export default function RecordWorkbench({ ...@@ -600,7 +787,7 @@ export default function RecordWorkbench({
</div> </div>
{hasColumnGroups ? ( {hasColumnGroups ? (
<div className="flex flex-wrap items-center gap-2 border-b border-zinc-100 bg-zinc-50/50 px-6 py-3"> <div className={clsx('flex flex-wrap items-center gap-2 border-b border-zinc-100 bg-zinc-50/50 px-6 py-3', fixedTablePanelClass)}>
<span className="mr-1 text-xs font-medium text-slate-400">列分组</span> <span className="mr-1 text-xs font-medium text-slate-400">列分组</span>
{columnGroupMeta.groups.map((group) => ( {columnGroupMeta.groups.map((group) => (
<ColumnGroupButton <ColumnGroupButton
...@@ -614,95 +801,108 @@ export default function RecordWorkbench({ ...@@ -614,95 +801,108 @@ export default function RecordWorkbench({
) : null} ) : null}
{error ? ( {error ? (
<div className="border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700"> <div className={clsx('border-b border-zinc-100 bg-rose-50/70 px-6 py-4 text-sm text-rose-700', fixedTablePanelClass)}>
{error} {error}
</div> </div>
) : null} ) : null}
{states?.success ? ( {states?.success ? (
<div className="border-b border-zinc-100 bg-emerald-50/70 px-6 py-4 text-sm text-emerald-700"> <div className={clsx('border-b border-zinc-100 bg-emerald-50/70 px-6 py-4 text-sm text-emerald-700', fixedTablePanelClass)}>
{states.success} {states.success}
</div> </div>
) : null} ) : null}
{loading ? ( {loading ? (
<div className="px-6 py-6"> <div className={stateContentClass}>
<EmptyState <EmptyState
title={states?.loadingTitle || '正在加载列表'} title={states?.loadingTitle || '正在加载列表'}
description={states?.loadingDescription || '正在读取对象结构、视图配置和记录数据,请稍候。'} description={states?.loadingDescription || '正在读取对象结构、视图配置和记录数据,请稍候。'}
/> />
</div> </div>
) : totalRecords === 0 ? ( ) : pageRecordCount === 0 ? (
<div className="px-6 py-6"> <div className={stateContentClass}>
<EmptyState <EmptyState
title={states?.emptyTitle || '当前暂无数据'} title={states?.emptyTitle || '当前暂无数据'}
description={states?.emptyDescription || '可以切换视图或调整筛选条件后重试。'} description={states?.emptyDescription || '可以切换视图或调整筛选条件后重试。'}
/> />
</div> </div>
) : ( ) : (
<div className="px-3 pb-3 pt-2"> <>
<Table bleed dense className="[--gutter:--spacing(3)]"> <div ref={tableScrollRef} className={tableContentClass}>
<TableHead> <Table bleed dense scrollable={!stickyTable} className="[--gutter:--spacing(3)]">
{showColumnGroupHeader ? ( <TableHead className={tableHeadClass}>
<TableRow className="bg-zinc-50/90"> {showColumnGroupHeader ? (
{selectable ? ( <TableRow className="bg-zinc-50">
<TableHeader {selectable ? (
rowSpan={2}
className="w-14 sticky left-0 z-40 bg-zinc-50/95 backdrop-blur shadow-[10px_0_20px_rgba(15,23,42,0.06)]"
style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
>
<div className="flex justify-center">
<Checkbox
checked={totalRecords > 0 && selectedIds.length === totalRecords}
onChange={(checked) => {
if (!checked) {
onSelectionChange?.([]);
return;
}
onSelectionChange?.(records.map((record) => getRecordId(record)));
}}
color="rose"
aria-label="全选当前视图记录"
/>
</div>
</TableHeader>
) : null}
{columnGroupSegments.map((segment) => {
const firstColumn = segment.columns[0];
const stickyMeta = stickyColumns.stickyMeta[firstColumn.field];
return (
<TableHeader <TableHeader
key={segment.key} rowSpan={2}
colSpan={segment.columns.length} className="w-14 sticky left-0 z-40 bg-zinc-50/95 backdrop-blur shadow-[10px_0_20px_rgba(15,23,42,0.06)]"
style={{ style={{ width: SELECTION_COLUMN_WIDTH, minWidth: SELECTION_COLUMN_WIDTH }}
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx(
'text-xs font-semibold text-slate-500',
segment.group ? 'bg-rose-50/80 text-rose-700' : 'bg-zinc-50/95 text-slate-300',
getStickyCellClass(stickyMeta, 'header'),
)}
> >
{segment.group ? ( <div className="flex justify-center">
<button <Checkbox
type="button" checked={pageRecordCount > 0 && selectedCurrentPageIds.length === pageRecordCount}
onClick={() => toggleColumnGroup(segment.group.id)} onChange={(checked) => {
className="inline-flex min-h-8 items-center gap-2 rounded-full px-2 py-1 transition hover:bg-white/70" if (!checked) {
aria-label={`折叠${segment.group.name}`} onSelectionChange?.(
> selectedIds.filter((id) => !currentPageRecordIds.includes(id)),
<span aria-hidden="true">-</span> );
<span>{segment.group.name}</span> return;
<span className="rounded-full bg-white/80 px-2 py-0.5 text-[11px] text-rose-500"> }
{segment.group.children.length} onSelectionChange?.(
</span> Array.from(new Set([...selectedIds, ...currentPageRecordIds])),
</button> );
) : null} }}
color="rose"
aria-label="全选当前页记录"
/>
</div>
</TableHeader> </TableHeader>
); ) : null}
})}
{hasExpandColumn ? (
<TableHeader
rowSpan={2}
className="w-12 bg-zinc-50/95 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400"
style={{ width: 48, minWidth: 48 }}
/>
) : null}
{columnGroupSegments.map((segment) => {
const firstColumn = segment.columns[0];
const stickyMeta = stickyColumns.stickyMeta[firstColumn.field];
return (
<TableHeader
key={segment.key}
colSpan={segment.columns.length}
style={{
left: stickyMeta?.side === 'left' ? `${stickyMeta.offset}px` : undefined,
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}}
className={clsx(
'text-xs font-semibold text-slate-500',
segment.group ? 'bg-rose-50/80 text-rose-700' : 'bg-zinc-50/95 text-slate-300',
getStickyCellClass(stickyMeta, 'header'),
)}
>
{segment.group ? (
<button
type="button"
onClick={() => toggleColumnGroup(segment.group.id)}
className="inline-flex min-h-8 items-center gap-2 rounded-full px-2 py-1 transition hover:bg-white/70"
aria-label={`折叠${segment.group.name}`}
>
<span aria-hidden="true">-</span>
<span>{segment.group.name}</span>
<span className="rounded-full bg-white/80 px-2 py-0.5 text-[11px] text-rose-500">
{segment.group.children.length}
</span>
</button>
) : null}
</TableHeader>
);
})}
{rowActions.length > 0 ? ( {rowActions.length > 0 ? (
<TableHeader <TableHeader
...@@ -716,7 +916,7 @@ export default function RecordWorkbench({ ...@@ -716,7 +916,7 @@ export default function RecordWorkbench({
</TableRow> </TableRow>
) : null} ) : null}
<TableRow className="bg-zinc-50/80"> <TableRow className="bg-zinc-50">
{selectable && !showColumnGroupHeader ? ( {selectable && !showColumnGroupHeader ? (
<TableHeader <TableHeader
className="w-14 sticky left-0 z-40 bg-zinc-50/95 backdrop-blur shadow-[10px_0_20px_rgba(15,23,42,0.06)]" className="w-14 sticky left-0 z-40 bg-zinc-50/95 backdrop-blur shadow-[10px_0_20px_rgba(15,23,42,0.06)]"
...@@ -724,21 +924,32 @@ export default function RecordWorkbench({ ...@@ -724,21 +924,32 @@ export default function RecordWorkbench({
> >
<div className="flex justify-center"> <div className="flex justify-center">
<Checkbox <Checkbox
checked={totalRecords > 0 && selectedIds.length === totalRecords} checked={pageRecordCount > 0 && selectedCurrentPageIds.length === pageRecordCount}
onChange={(checked) => { onChange={(checked) => {
if (!checked) { if (!checked) {
onSelectionChange?.([]); onSelectionChange?.(
selectedIds.filter((id) => !currentPageRecordIds.includes(id)),
);
return; return;
} }
onSelectionChange?.(records.map((record) => getRecordId(record))); onSelectionChange?.(
Array.from(new Set([...selectedIds, ...currentPageRecordIds])),
);
}} }}
color="rose" color="rose"
aria-label="全选当前视图记录" aria-label="全选当前页记录"
/> />
</div> </div>
</TableHeader> </TableHeader>
) : null} ) : null}
{hasExpandColumn && !showColumnGroupHeader ? (
<TableHeader
className="w-12 bg-zinc-50 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400"
style={{ width: 48, minWidth: 48 }}
/>
) : null}
{stickyColumns.orderedColumns.map((column) => { {stickyColumns.orderedColumns.map((column) => {
const stickyMeta = stickyColumns.stickyMeta[column.field]; const stickyMeta = stickyColumns.stickyMeta[column.field];
...@@ -752,7 +963,7 @@ export default function RecordWorkbench({ ...@@ -752,7 +963,7 @@ export default function RecordWorkbench({
right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined, right: stickyMeta?.side === 'right' ? `${stickyMeta.offset}px` : undefined,
}} }}
className={clsx( className={clsx(
'text-xs font-semibold uppercase tracking-[0.18em] text-slate-400', 'bg-zinc-50 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400',
getStickyCellClass(stickyMeta, 'header'), getStickyCellClass(stickyMeta, 'header'),
)} )}
> >
...@@ -775,6 +986,8 @@ export default function RecordWorkbench({ ...@@ -775,6 +986,8 @@ export default function RecordWorkbench({
<TableBody> <TableBody>
{records.map((record) => { {records.map((record) => {
const recordId = getRecordId(record); const recordId = getRecordId(record);
const expandable = hasExpandableRows && (isRowExpandable ? isRowExpandable(record) : true);
const expanded = Boolean(expandedRowIds[recordId]);
const rowContext = { const rowContext = {
record, record,
records, records,
...@@ -785,7 +998,8 @@ export default function RecordWorkbench({ ...@@ -785,7 +998,8 @@ export default function RecordWorkbench({
const visibleRowActions = rowActions.filter((action) => !isActionHidden(action, rowContext)); const visibleRowActions = rowActions.filter((action) => !isActionHidden(action, rowContext));
return ( return (
<TableRow key={recordId} className="align-top"> <Fragment key={recordId}>
<TableRow className="align-top">
{selectable ? ( {selectable ? (
<TableCell <TableCell
className="align-top sticky left-0 z-30 bg-white shadow-[10px_0_20px_rgba(15,23,42,0.05)]" className="align-top sticky left-0 z-30 bg-white shadow-[10px_0_20px_rgba(15,23,42,0.05)]"
...@@ -808,6 +1022,25 @@ export default function RecordWorkbench({ ...@@ -808,6 +1022,25 @@ export default function RecordWorkbench({
</TableCell> </TableCell>
) : null} ) : null}
{hasExpandColumn ? (
<TableCell className="w-12 align-top" style={{ width: 48, minWidth: 48 }}>
{expandable ? (
<button
type="button"
onClick={() => toggleExpandedRow(recordId)}
className="mt-1 inline-flex size-8 items-center justify-center rounded-full text-slate-500 transition hover:bg-zinc-100 hover:text-slate-900"
aria-label={`${expanded ? '收起' : '展开'} ${getRecordLabel(record)}`}
aria-expanded={expanded}
>
<ChevronRightIcon
className={clsx('size-4 transition-transform', expanded && 'rotate-90')}
aria-hidden="true"
/>
</button>
) : null}
</TableCell>
) : null}
{stickyColumns.orderedColumns.map((column) => { {stickyColumns.orderedColumns.map((column) => {
const stickyMeta = stickyColumns.stickyMeta[column.field]; const stickyMeta = stickyColumns.stickyMeta[column.field];
return ( return (
...@@ -827,7 +1060,13 @@ export default function RecordWorkbench({ ...@@ -827,7 +1060,13 @@ export default function RecordWorkbench({
} }
if (renderCell) { if (renderCell) {
const customNode = renderCell({ record, column }); const customNode = renderCell({
record,
column,
expandable,
expanded,
toggleExpanded: () => toggleExpandedRow(recordId),
});
if (customNode !== undefined) { if (customNode !== undefined) {
return customNode; return customNode;
} }
...@@ -861,11 +1100,21 @@ export default function RecordWorkbench({ ...@@ -861,11 +1100,21 @@ export default function RecordWorkbench({
</TableCell> </TableCell>
) : null} ) : null}
</TableRow> </TableRow>
{expanded && expandable ? (
<TableRow key={`${recordId}-expanded`} className="bg-zinc-50/70">
<TableCell colSpan={tableColSpan} className="px-6 py-4">
{renderExpandedRow({ record, rowContext })}
</TableCell>
</TableRow>
) : null}
</Fragment>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<WorkbenchPagination pagination={pagination} />
</>
)} )}
</section> </section>
......
...@@ -15,6 +15,8 @@ import { ...@@ -15,6 +15,8 @@ import {
isViewAccessible, isViewAccessible,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const EMPTY_ARRAY = [];
function CloseIcon() { function CloseIcon() {
return ( return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5"> <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="size-5">
...@@ -46,6 +48,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -46,6 +48,7 @@ const RecordDetailDrawer = (props, ref) => {
readonly = false, readonly = false,
filterView = [], filterView = [],
preferredViewName, preferredViewName,
disabledFields = EMPTY_ARRAY,
} = props; } = props;
const [refreshKey, setRefreshKey] = useState(() => Date.now()); const [refreshKey, setRefreshKey] = useState(() => Date.now());
...@@ -122,6 +125,15 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -122,6 +125,15 @@ const RecordDetailDrawer = (props, ref) => {
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0]; const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
const drawerWidth = isFullscreen ? '100vw' : typeof width === 'number' ? `${width}px` : width; const drawerWidth = isFullscreen ? '100vw' : typeof width === 'number' ? `${width}px` : width;
const disabledFieldSet = useMemo(
() =>
new Set(
disabledFields.flatMap((fieldName) => [fieldName, `metadata.${fieldName}`]),
),
[disabledFields],
);
const isDrawerFieldDisabled = (fieldPath, currentView) =>
disabledFieldSet.has(fieldPath) || isFieldDisabled(fieldPath, currentView);
const closeDrawer = () => { const closeDrawer = () => {
onClose?.(); onClose?.();
...@@ -209,7 +221,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -209,7 +221,7 @@ const RecordDetailDrawer = (props, ref) => {
onFieldUpdate={handleFieldUpdate} onFieldUpdate={handleFieldUpdate}
onDataChange={onDataChange} onDataChange={onDataChange}
readonly={readonly} readonly={readonly}
isFieldDisabled={isFieldDisabled} isFieldDisabled={isDrawerFieldDisabled}
/> />
)} )}
</main> </main>
......
import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import {
batchUpdateRecords,
createNotice,
executeAction,
loadRecord,
queryRecords,
queryUsers,
sendNotice,
updateRecord,
uploadFile,
} from '@/services/DataService';
import { normalizeText, toArray } from '@/utils/subjectView';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { PencilSquareIcon } from '@heroicons/react/24/outline';
import { useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
import { UserContext } from '@/wrapper/Auth';
import {
AUDIT_STATUS_APPROVED,
AUDIT_STATUS_PENDING,
AUDIT_STATUS_REJECTED,
EDITOR_STATUS_COMPLETED,
EDITOR_STATUS_PENDING,
FLOW_MODE_BATCH,
FLOW_MODE_DEPARTMENT,
PROBLEM_RECTIFICATION_SUBJECT,
buildDepartmentParentBatchUpdateData,
getProblemRectificationFlowTableUrl,
normalizeDepartmentRectificationItems,
} from '../problemRectificationListService';
const FLOW_MANAGED_PARENT_FIELDS = [
'fill_in_the_people',
'review_people',
'audit_status',
'final_audit_status',
'department_rectification_items',
];
const DEPARTMENT_MANAGED_PARENT_FIELDS = [
...FLOW_MANAGED_PARENT_FIELDS,
'rectification_progress',
'rectification_situation',
'the_time_of_rectification',
'rectification_proof_materials',
];
function toId(value) {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
return value._id || value.id || value.name || '';
}
function normalizeRestrictionValue(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeText(item)).filter(Boolean);
}
if (typeof value === 'string') {
return value.split(',').map((item) => normalizeText(item)).filter(Boolean);
}
return [];
}
function buildProgressFallback(step) {
return `${step}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
function formatDateInput(value) {
if (!value) {
return '';
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : normalizeText(value).slice(0, 10);
}
function formatDateDisplay(value) {
if (!value) {
return '-';
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : normalizeText(value) || '-';
}
function StatusPill({ value }) {
const toneClass =
value === EDITOR_STATUS_COMPLETED || value === AUDIT_STATUS_APPROVED
? 'bg-emerald-50 text-emerald-700 ring-emerald-100'
: value === AUDIT_STATUS_REJECTED
? 'bg-rose-50 text-rose-700 ring-rose-100'
: 'bg-amber-50 text-amber-700 ring-amber-100';
return (
<span className={clsx('inline-flex rounded-full px-2.5 py-1 text-xs font-medium ring-1', toneClass)}>
{value || AUDIT_STATUS_PENDING}
</span>
);
}
function collectUsers(ids = []) {
return Array.from(new Set(ids.map((item) => toId(item)).filter(Boolean)));
}
async function createProgressTask({ recipientId, title, currentUser, returnUrl }) {
if (!recipientId) {
return '';
}
try {
const result = await executeAction('oa_notice:createProgress', {
title,
classify: '流程待办',
contentType: '流程待办',
receiveUserId: recipientId,
receiveUserName: recipientId,
receiveDisplayName: recipientId,
sendUserId: currentUser?._id || '',
sendUserName: currentUser?.username || '',
sendDisplayName: currentUser?.display_name || '',
returnUrl,
});
return result?.data?.taskId || buildProgressFallback('progress');
} catch (error) {
return buildProgressFallback('progress');
}
}
async function completeProgressTask(progressId) {
if (!progressId) {
return;
}
try {
await executeAction('oa_notice:updateProgressStatus', {
taskId: progressId,
});
} catch (error) {
// OA 失败不阻断业务状态回写。
}
}
async function sendNoticeToUsers({ title, content, from, to }) {
const receivers = collectUsers(to);
if (receivers.length === 0) {
return;
}
const notice = await createNotice({
title,
content,
type: 'internal',
from: from || '',
to: receivers,
settings: {},
});
await sendNotice(notice.name);
}
function buildDepartmentRows(records = []) {
return records.flatMap((record) => {
const items = normalizeDepartmentRectificationItems(record);
return items.map((item) => ({
key: `${record._id || record.name}-${item.item_id}`,
flowMode: FLOW_MODE_DEPARTMENT,
record,
item,
itemId: item.item_id,
recordId: record._id || record.name,
title: record.title || record.metadata?.project_name || '-',
projectName: record.metadata?.project_name || '-',
problemBrief: record.metadata?.problem_brief || '',
relatedIssueDomain: toArray(record.metadata?.related_issue_domain).join('、'),
department: item.responsible_department || '-',
editor: item.editor,
auditor: item.auditor,
progress: item.rectification_progress || '',
situation: item.rectification_situation || '',
dueDate: item.the_time_of_rectification || '',
proof: item.rectification_proof_materials || null,
editorStatus: item.editor_status || EDITOR_STATUS_PENDING,
auditStatus: item.audit_status || AUDIT_STATUS_PENDING,
finalAuditStatus: item.final_audit_status || AUDIT_STATUS_PENDING,
progressIdEditor: item.progress_id_editor || '',
progressIdAuditor: item.progress_id_auditor || '',
progressIdSender: item.progress_id_sender || '',
}));
});
}
function buildBatchRows(records = [], metadata = {}) {
const editor = toId(toArray(metadata.editor)[0]);
const auditor = toId(toArray(metadata.auditor)[0]);
return records.map((record) => {
const recordMetadata = record.metadata || {};
return {
key: record._id || record.name,
flowMode: FLOW_MODE_BATCH,
record,
item: null,
itemId: record._id || record.name,
recordId: record._id || record.name,
title: record.title || recordMetadata.project_name || '-',
projectName: recordMetadata.project_name || '-',
problemBrief: recordMetadata.problem_brief || '',
relatedIssueDomain: toArray(recordMetadata.related_issue_domain).join('、'),
department: toArray(recordMetadata.responsible_department).join('、') || '-',
editor,
auditor,
progress: recordMetadata.rectification_progress || '',
situation: recordMetadata.rectification_situation || '',
dueDate: recordMetadata.the_time_of_rectification || '',
proof: recordMetadata.rectification_proof_materials || null,
editorStatus: metadata.editor_status || EDITOR_STATUS_PENDING,
auditStatus: recordMetadata.audit_status || AUDIT_STATUS_PENDING,
finalAuditStatus: recordMetadata.final_audit_status || AUDIT_STATUS_PENDING,
progressIdEditor: toArray(metadata.progress_id_editor)[0] || '',
progressIdAuditor: toArray(metadata.progress_id_auditor)[0] || '',
progressIdSender: metadata.progress_id_sender || '',
};
});
}
export default function ProblemRectificationFlowTablePage() {
const userInfo = useContext(UserContext);
const [searchParams] = useSearchParams();
const messageId = searchParams.get('id') || '';
const [loading, setLoading] = useState(true);
const [submittingKey, setSubmittingKey] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [messageRecord, setMessageRecord] = useState(null);
const [records, setRecords] = useState([]);
const [userLabels, setUserLabels] = useState({});
const [drafts, setDrafts] = useState({});
const [editingRecordName, setEditingRecordName] = useState('');
const metadata = useMemo(() => messageRecord?.metadata || {}, [messageRecord]);
const flowMode = metadata.flow_mode || FLOW_MODE_BATCH;
const rows = useMemo(
() =>
flowMode === FLOW_MODE_DEPARTMENT
? buildDepartmentRows(records)
: buildBatchRows(records, metadata),
[flowMode, metadata, records],
);
const currentUserId = toId(userInfo?._id);
const senderId = toId(metadata.sender);
const isSender = Boolean(currentUserId && senderId === currentUserId);
const returnUrl = getProblemRectificationFlowTableUrl(
messageRecord?.name || messageRecord?._id || messageId,
);
const loadFlow = async () => {
if (!messageId) {
setError('缺少流程 ID。');
setLoading(false);
return;
}
setLoading(true);
setError('');
try {
const nextMessage = await loadRecord(messageId);
const nextMetadata = nextMessage?.metadata || {};
const recordIds = normalizeRestrictionValue(nextMetadata.restriction_value);
const result = recordIds.length
? await queryRecords({
subject: nextMetadata.subject_name || PROBLEM_RECTIFICATION_SUBJECT,
page: 0,
page_size: 1000,
mode: 'full',
filter: {
_id: { $in: recordIds },
},
})
: { records: [] };
setMessageRecord(nextMessage);
setRecords(result?.records || []);
setDrafts({});
} catch (requestError) {
setError(requestError?.message || '流程表格加载失败,请稍后重试。');
setMessageRecord(null);
setRecords([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFlow();
}, [messageId]);
useEffect(() => {
const userIds = Array.from(
new Set(rows.flatMap((row) => [row.editor, row.auditor]).map(toId).filter(Boolean)),
);
if (userIds.length === 0) {
setUserLabels({});
return undefined;
}
let cancelled = false;
queryUsers(0, 1000, { _id: { $in: userIds } })
.then((result) => {
if (cancelled) {
return;
}
setUserLabels(
Object.fromEntries(
(result?.users || []).map((user) => [
user._id,
user.display_name || user.username || user._id,
]),
),
);
})
.catch(() => {
if (!cancelled) {
setUserLabels({});
}
});
return () => {
cancelled = true;
};
}, [rows]);
const getUserLabel = (value) => {
const userId = toId(value);
return userLabels[userId] || userId || '-';
};
const getDraft = (row) => ({
progress: row.progress,
situation: row.situation,
dueDate: formatDateInput(row.dueDate),
proof: row.proof,
...(drafts[row.key] || {}),
});
const setDraftValue = (rowKey, patch) => {
setDrafts((current) => ({
...current,
[rowKey]: {
...(current[rowKey] || {}),
...patch,
},
}));
};
const updateDepartmentItem = async (row, patch) => {
const nextItems = normalizeDepartmentRectificationItems(row.record).map((item) =>
item.item_id === row.itemId
? {
...item,
...patch,
updated_by: currentUserId,
updated_at: dayjs().toISOString(),
}
: item,
);
await batchUpdateRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: {
_id: row.recordId,
},
data: buildDepartmentParentBatchUpdateData(nextItems),
});
};
const updateBatchRow = async (row, patch) => {
await updateRecord(row.recordId, {
metadata: {
rectification_progress: patch.rectification_progress ?? row.progress,
rectification_situation: patch.rectification_situation ?? row.situation,
the_time_of_rectification: patch.the_time_of_rectification ?? row.dueDate,
rectification_proof_materials: patch.rectification_proof_materials ?? row.proof,
audit_status: patch.audit_status ?? row.auditStatus,
final_audit_status: patch.final_audit_status ?? row.finalAuditStatus,
},
});
};
const withRowSubmit = async (row, handler) => {
setSubmittingKey(row.key);
setError('');
setSuccess('');
try {
await handler();
await loadFlow();
setSuccess('操作已完成。');
} catch (requestError) {
setError(requestError?.message || '操作失败,请稍后重试。');
} finally {
setSubmittingKey('');
}
};
const saveRowDraft = async (row) => {
const draft = getDraft(row);
const patch = {
rectification_progress: draft.progress,
rectification_situation: draft.situation,
the_time_of_rectification: draft.dueDate ? dayjs(draft.dueDate).toISOString() : null,
rectification_proof_materials: draft.proof || null,
};
if (row.flowMode === FLOW_MODE_DEPARTMENT) {
await updateDepartmentItem(row, patch);
return patch;
}
await updateBatchRow(row, patch);
return patch;
};
const handleSave = async (row) => {
await withRowSubmit(row, async () => {
await saveRowDraft(row);
});
};
const handleUpload = async (row, event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
await withRowSubmit(row, async () => {
const uploaded = await uploadFile(file);
const fileValue = uploaded?.file || uploaded?.data || uploaded;
setDraftValue(row.key, { proof: fileValue });
if (row.flowMode === FLOW_MODE_DEPARTMENT) {
await updateDepartmentItem(row, { rectification_proof_materials: fileValue });
return;
}
await updateBatchRow(row, {
rectification_progress: row.progress,
rectification_situation: row.situation,
the_time_of_rectification: row.dueDate || null,
rectification_proof_materials: fileValue,
audit_status: row.auditStatus,
final_audit_status: row.finalAuditStatus,
});
});
};
const handleEditorSubmit = async (row) => {
await withRowSubmit(row, async () => {
const savedPatch = await saveRowDraft(row);
const progressIdAuditor = await createProgressTask({
recipientId: row.auditor,
title: `${dayjs().format('YYYY-MM-DD')} 问题整改表复核 - ${row.department}`,
currentUser: userInfo,
returnUrl,
});
await completeProgressTask(row.progressIdEditor);
if (row.flowMode === FLOW_MODE_DEPARTMENT) {
await updateDepartmentItem(row, {
...savedPatch,
editor_status: EDITOR_STATUS_COMPLETED,
audit_status: AUDIT_STATUS_PENDING,
progress_id_auditor: progressIdAuditor,
});
} else {
await updateBatchRow(row, {
...savedPatch,
audit_status: AUDIT_STATUS_PENDING,
final_audit_status: row.finalAuditStatus,
});
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
progress_id_auditor: [progressIdAuditor],
editor_status: EDITOR_STATUS_COMPLETED,
auditor_status: EDITOR_STATUS_PENDING,
},
});
}
await sendNoticeToUsers({
title: '问题整改表待复核',
content: `请点击链接复核问题整改表:${returnUrl}`,
from: currentUserId,
to: [row.auditor],
});
});
};
const handleAuditorDecision = async (row, approved) => {
await withRowSubmit(row, async () => {
await completeProgressTask(row.progressIdAuditor);
const nextAuditStatus = approved ? AUDIT_STATUS_APPROVED : AUDIT_STATUS_REJECTED;
const progressIdSender = approved
? await createProgressTask({
recipientId: senderId,
title: `${dayjs().format('YYYY-MM-DD')} 问题整改表最终审批 - ${row.department}`,
currentUser: userInfo,
returnUrl,
})
: '';
const progressIdEditor = approved
? ''
: await createProgressTask({
recipientId: row.editor,
title: `复核驳回,请重新填写问题整改表 ${dayjs().format('YYYY-MM-DD')} - ${row.department}`,
currentUser: userInfo,
returnUrl,
});
if (row.flowMode === FLOW_MODE_DEPARTMENT) {
await updateDepartmentItem(row, {
audit_status: nextAuditStatus,
editor_status: approved ? EDITOR_STATUS_COMPLETED : EDITOR_STATUS_PENDING,
final_audit_status: approved ? AUDIT_STATUS_PENDING : row.finalAuditStatus,
progress_id_sender: progressIdSender,
progress_id_editor: approved ? row.progressIdEditor : progressIdEditor,
});
} else {
await updateBatchRow(row, {
rectification_progress: row.progress,
rectification_situation: row.situation,
the_time_of_rectification: row.dueDate,
rectification_proof_materials: row.proof,
audit_status: nextAuditStatus,
final_audit_status: approved ? AUDIT_STATUS_PENDING : row.finalAuditStatus,
});
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
progress_id_sender: progressIdSender,
auditor_status: approved ? EDITOR_STATUS_COMPLETED : AUDIT_STATUS_REJECTED,
editor_status: approved ? EDITOR_STATUS_COMPLETED : EDITOR_STATUS_PENDING,
progress_id_editor: approved ? metadata.progress_id_editor : [progressIdEditor],
},
});
}
await sendNoticeToUsers({
title: approved ? '问题整改表待最终审批' : '问题整改表复核驳回',
content: approved
? `请点击链接最终审批问题整改表:${returnUrl}`
: `问题整改表复核未通过,请点击链接重新填写:${returnUrl}`,
from: currentUserId,
to: approved ? [senderId] : [row.editor],
});
});
};
const handleSenderDecision = async (row, approved) => {
await withRowSubmit(row, async () => {
await completeProgressTask(row.progressIdSender);
const nextFinalStatus = approved ? AUDIT_STATUS_APPROVED : AUDIT_STATUS_REJECTED;
const progressIdEditor = approved
? ''
: await createProgressTask({
recipientId: row.editor,
title: `最终审批驳回,请重新填写问题整改表 ${dayjs().format('YYYY-MM-DD')} - ${row.department}`,
currentUser: userInfo,
returnUrl,
});
if (row.flowMode === FLOW_MODE_DEPARTMENT) {
await updateDepartmentItem(row, {
final_audit_status: nextFinalStatus,
editor_status: approved ? row.editorStatus : EDITOR_STATUS_PENDING,
progress_id_editor: approved ? row.progressIdEditor : progressIdEditor,
});
} else {
await updateBatchRow(row, {
rectification_progress: row.progress,
rectification_situation: row.situation,
the_time_of_rectification: row.dueDate,
rectification_proof_materials: row.proof,
audit_status: row.auditStatus,
final_audit_status: nextFinalStatus,
});
if (!approved) {
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
progress_id_editor: [progressIdEditor],
editor_status: EDITOR_STATUS_PENDING,
},
});
}
}
if (!approved) {
await sendNoticeToUsers({
title: '问题整改表最终审批驳回',
content: `问题整改表最终审批未通过,请点击链接重新填写:${returnUrl}`,
from: currentUserId,
to: [row.editor],
});
}
});
};
const canEditRow = (row) =>
isSender ||
(row.editor === currentUserId && row.editorStatus !== EDITOR_STATUS_COMPLETED) ||
(row.auditor === currentUserId && row.auditStatus === AUDIT_STATUS_REJECTED);
const canSubmitRow = (row) =>
row.editor === currentUserId &&
row.editorStatus !== EDITOR_STATUS_COMPLETED;
const canAuditRow = (row) =>
row.auditor === currentUserId &&
row.editorStatus === EDITOR_STATUS_COMPLETED &&
row.auditStatus !== AUDIT_STATUS_APPROVED;
const canFinalAuditRow = (row) =>
senderId === currentUserId &&
row.auditStatus === AUDIT_STATUS_APPROVED &&
row.finalAuditStatus !== AUDIT_STATUS_APPROVED;
return (
<div className="space-y-6 p-6">
<section className="rounded-[30px] border border-white/80 bg-white/95 px-6 py-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-rose-600">
Problem Rectification Flow
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-slate-950">问题整改表格处理</h1>
<p className="mt-2 text-sm text-slate-500">
当前流程:{messageId || '-'};流程模式:{flowMode === FLOW_MODE_DEPARTMENT ? '按责任部门' : '批量简单推送'}
</p>
</div>
<Button outline onClick={loadFlow} disabled={loading || Boolean(submittingKey)}>
刷新
</Button>
</div>
</section>
{error ? (
<div className="rounded-[24px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded-[24px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{success}</div>
) : null}
<section className="overflow-hidden rounded-[30px] border border-white/80 bg-white/96 shadow-sm">
{loading ? (
<div className="px-6 py-14 text-center text-sm text-slate-500">正在加载流程表格...</div>
) : rows.length === 0 ? (
<div className="px-6 py-14 text-center text-sm text-slate-500">当前流程没有可处理的整改明细。</div>
) : (
<Table bleed dense className="[--gutter:--spacing(4)]">
<TableHead>
<TableRow className="bg-zinc-50">
<TableHeader>问题</TableHeader>
<TableHeader>问题简述</TableHeader>
<TableHeader>涉及业务领域</TableHeader>
<TableHeader>责任部门</TableHeader>
<TableHeader>填写人 / 审批人</TableHeader>
<TableHeader>整改进度</TableHeader>
<TableHeader>预计完成</TableHeader>
<TableHeader>整改情况</TableHeader>
<TableHeader>证明材料</TableHeader>
<TableHeader>状态</TableHeader>
<TableHeader>操作</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => {
const draft = getDraft(row);
const editable = canEditRow(row);
const submitting = submittingKey === row.key;
return (
<TableRow key={row.key} className="align-top">
<TableCell className="min-w-64">
<div className="font-medium text-slate-900">{row.title}</div>
<div className="mt-1 text-xs text-slate-500">{row.projectName}</div>
{isSender ? (
<Button
plain
className="mt-2"
disabled={Boolean(submittingKey)}
onClick={() => setEditingRecordName(row.record?.name || row.recordId)}
>
<PencilSquareIcon data-slot="icon" />
编辑问题记录
</Button>
) : null}
</TableCell>
<TableCell className="min-w-72 max-w-96">
<div className="whitespace-pre-wrap break-words text-sm text-slate-700">
{row.problemBrief || '-'}
</div>
</TableCell>
<TableCell className="min-w-40 max-w-64">
<div className="whitespace-pre-wrap break-words text-sm text-slate-700">
{row.relatedIssueDomain || '-'}
</div>
</TableCell>
<TableCell>{row.department}</TableCell>
<TableCell className="min-w-48">
<div className="text-xs text-slate-500">填:{getUserLabel(row.editor)}</div>
<div className="mt-1 text-xs text-slate-500">审:{getUserLabel(row.auditor)}</div>
</TableCell>
<TableCell className="min-w-36">
{editable ? (
<Select
value={draft.progress}
onChange={(event) => setDraftValue(row.key, { progress: event.target.value })}
>
<option value="">请选择</option>
<option value="已整改">已整改</option>
<option value="整改中">整改中</option>
<option value="部分整改">部分整改</option>
<option value="不适用">不适用</option>
<option value="已优化">已优化</option>
</Select>
) : (
draft.progress || '-'
)}
</TableCell>
<TableCell className="min-w-40">
{editable ? (
<Input
type="date"
value={draft.dueDate}
onChange={(event) => setDraftValue(row.key, { dueDate: event.target.value })}
/>
) : (
formatDateDisplay(row.dueDate)
)}
</TableCell>
<TableCell className="min-w-72">
{editable ? (
<Textarea
rows={3}
value={draft.situation}
onChange={(event) => setDraftValue(row.key, { situation: event.target.value })}
/>
) : (
<span className="whitespace-pre-wrap">{row.situation || '-'}</span>
)}
</TableCell>
<TableCell className="min-w-52">
{row.proof?.url ? (
<a href={row.proof.url} target="_blank" rel="noreferrer" className="text-sm font-medium text-sky-700">
{row.proof.name || '查看附件'}
</a>
) : (
<div className="text-sm text-slate-400">未上传</div>
)}
{editable ? (
<input
type="file"
className="mt-2 block w-full text-xs text-slate-500"
disabled={submitting}
onChange={(event) => handleUpload(row, event)}
/>
) : null}
</TableCell>
<TableCell className="min-w-44">
<div className="space-y-1.5">
<StatusPill value={row.editorStatus} />
<StatusPill value={row.auditStatus} />
<StatusPill value={row.finalAuditStatus} />
</div>
</TableCell>
<TableCell className="min-w-52">
<div className="flex flex-wrap gap-2">
{editable ? (
<Button outline disabled={submitting} onClick={() => handleSave(row)}>
保存
</Button>
) : null}
{canSubmitRow(row) ? (
<Button color="blue" disabled={submitting} onClick={() => handleEditorSubmit(row)}>
送审
</Button>
) : null}
{canAuditRow(row) ? (
<>
<Button color="emerald" disabled={submitting} onClick={() => handleAuditorDecision(row, true)}>
审批通过
</Button>
<Button color="red" disabled={submitting} onClick={() => handleAuditorDecision(row, false)}>
驳回
</Button>
</>
) : null}
{canFinalAuditRow(row) ? (
<>
<Button color="blue" disabled={submitting} onClick={() => handleSenderDecision(row, true)}>
最终通过
</Button>
<Button color="red" disabled={submitting} onClick={() => handleSenderDecision(row, false)}>
最终驳回
</Button>
</>
) : null}
{!editable && !canAuditRow(row) && !canFinalAuditRow(row) ? (
<span className="text-xs text-slate-400">当前无可执行操作</span>
) : null}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</section>
<RecordDetailDrawer
open={Boolean(editingRecordName)}
recordName={editingRecordName}
subjectName={PROBLEM_RECTIFICATION_SUBJECT}
readonly={!isSender}
disabledFields={
flowMode === FLOW_MODE_DEPARTMENT
? DEPARTMENT_MANAGED_PARENT_FIELDS
: FLOW_MANAGED_PARENT_FIELDS
}
onClose={() => {
setEditingRecordName('');
loadFlow();
}}
/>
</div>
);
}
...@@ -8,6 +8,14 @@ import { ...@@ -8,6 +8,14 @@ import {
} from '@/catalyst-ui-kit/javascript/dialog'; } from '@/catalyst-ui-kit/javascript/dialog';
import { Input } from '@/catalyst-ui-kit/javascript/input'; import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select'; import { Select } from '@/catalyst-ui-kit/javascript/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea'; import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
...@@ -18,12 +26,14 @@ import { ...@@ -18,12 +26,14 @@ import {
createNotice, createNotice,
createRecord, createRecord,
executeAction, executeAction,
loadRecord,
loadUser, loadUser,
queryUsers, queryUsers,
sendNotice, sendNotice,
updateRecord, updateRecord,
} from '@/services/DataService'; } from '@/services/DataService';
import * as Headless from '@headlessui/react'; import * as Headless from '@headlessui/react';
import { ChevronRightIcon } from '@heroicons/react/20/solid';
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, useState } from 'react';
...@@ -35,15 +45,28 @@ import { ...@@ -35,15 +45,28 @@ import {
normalizeText, normalizeText,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { import {
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
PROBLEM_RECTIFICATION_SUBJECT, PROBLEM_RECTIFICATION_SUBJECT,
buildGridViewCountMap, AUDIT_STATUS_PENDING,
DEPARTMENT_RECTIFICATION_FIELD,
EDITOR_STATUS_PENDING,
FLOW_MODE_BATCH,
FLOW_MODE_DEPARTMENT,
PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
buildDepartmentAssignmentsFromRecord,
buildDepartmentParentBatchUpdateData,
buildDepartmentRectificationItems,
buildGridViews, buildGridViews,
buildInitialCreateValues, buildInitialCreateValues,
buildProblemRectificationConfiguredListView, buildProblemRectificationConfiguredListView,
buildProblemRectificationCreateFormFields, buildProblemRectificationCreateFormFields,
buildProblemRectificationCreatePayload, buildProblemRectificationCreatePayload,
getProblemRectificationFlowTableUrl,
getRecordCellText, getRecordCellText,
loadProblemRectificationListData,
loadProblemRectificationListResources, loadProblemRectificationListResources,
loadProblemRectificationViewCountMap,
normalizeDepartmentRectificationItems,
} from '../problemRectificationListService'; } from '../problemRectificationListService';
const FIELD_HINTS = { const FIELD_HINTS = {
...@@ -284,6 +307,89 @@ function RemoteUserSelect({ label, value, onChange, placeholder, onSearch }) { ...@@ -284,6 +307,89 @@ function RemoteUserSelect({ label, value, onChange, placeholder, onSearch }) {
); );
} }
function MultiSelectDropdown({ value, options, onChange, placeholder = '请选择' }) {
const selectedValues = Array.isArray(value) ? value : [];
const optionByValue = useMemo(
() => new Map(options.map((option) => [option.value, option])),
[options],
);
const selectedLabels = selectedValues
.map((item) => optionByValue.get(item)?.label || item)
.filter(Boolean);
return (
<div className="space-y-2">
<Headless.Listbox value={selectedValues} onChange={onChange} multiple>
<div className="relative">
<Headless.ListboxButton className="relative block min-h-11 w-full rounded-lg border border-zinc-950/10 bg-white py-[calc(--spacing(2.5)-1px)] pl-[calc(--spacing(3.5)-1px)] pr-10 text-left text-base/6 text-zinc-950 shadow-sm outline-none transition data-focus:ring-2 data-focus:ring-blue-500 sm:min-h-9 sm:py-[calc(--spacing(1.5)-1px)] sm:pl-[calc(--spacing(3)-1px)] sm:text-sm/6">
{selectedLabels.length > 0 ? (
<span className="block truncate">
已选择 {selectedLabels.length} 项:{selectedLabels.join('、')}
</span>
) : (
<span className="block truncate text-zinc-500">{placeholder}</span>
)}
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="size-5 stroke-zinc-500 sm:size-4"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</Headless.ListboxButton>
<Headless.ListboxOptions className="absolute z-50 mt-2 max-h-72 w-full overflow-y-auto overscroll-contain rounded-xl border border-zinc-200 bg-white p-1 shadow-xl ring-1 ring-black/5 focus:outline-none">
{options.map((option) => (
<Headless.ListboxOption
key={option.value}
value={option.value}
className="group flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-700 data-focus:bg-rose-50 data-focus:text-slate-950"
>
{({ selected }) => (
<>
<span
className={clsx(
'flex size-4 shrink-0 items-center justify-center rounded border',
selected
? 'border-rose-500 bg-rose-500 text-white'
: 'border-zinc-300 bg-white text-transparent',
)}
aria-hidden="true"
>
✓
</span>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
</>
)}
</Headless.ListboxOption>
))}
</Headless.ListboxOptions>
</div>
</Headless.Listbox>
{selectedLabels.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedValues.map((item) => (
<button
key={item}
type="button"
onClick={() => onChange(selectedValues.filter((selected) => selected !== item))}
className="inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 ring-1 ring-rose-100 transition hover:bg-rose-100"
aria-label={`移除 ${optionByValue.get(item)?.label || item}`}
>
<span>{optionByValue.get(item)?.label || item}</span>
<span aria-hidden="true">×</span>
</button>
))}
</div>
) : null}
</div>
);
}
function CreateField({ fieldDef, value, onChange }) { function CreateField({ fieldDef, value, onChange }) {
const options = getSelectOptions(fieldDef); const options = getSelectOptions(fieldDef);
const isMultiple = Boolean(fieldDef?.multiple); const isMultiple = Boolean(fieldDef?.multiple);
...@@ -307,19 +413,23 @@ function CreateField({ fieldDef, value, onChange }) { ...@@ -307,19 +413,23 @@ function CreateField({ fieldDef, value, onChange }) {
} }
if (fieldType === 'select') { if (fieldType === 'select') {
if (isMultiple) {
return (
<MultiSelectDropdown
value={value || []}
options={options}
onChange={onChange}
placeholder="请选择"
/>
);
}
return ( return (
<Select <Select
multiple={isMultiple} value={value || ''}
value={isMultiple ? value || [] : value || ''} onChange={(event) => onChange(event.target.value)}
onChange={(event) => {
if (isMultiple) {
onChange(Array.from(event.target.selectedOptions).map((option) => option.value));
return;
}
onChange(event.target.value);
}}
> >
{!isMultiple ? <option value="">请选择</option> : null} <option value="">请选择</option>
{options.map((option) => ( {options.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
...@@ -358,30 +468,7 @@ function FieldValuePreview({ column, record }) { ...@@ -358,30 +468,7 @@ function FieldValuePreview({ column, record }) {
const value = getFieldValue(rawRecord, column.field); const value = getFieldValue(rawRecord, column.field);
if (fieldName === 'title') { if (fieldName === 'title') {
const tags = [ return <div className="font-medium text-slate-900">{record.title || '(无标题)'}</div>;
record.severityLevel ? { label: `严重程度 ${record.severityLevel}`, tone: resolveLevelTone(record.severityLevel) } : null,
record.overallRating ? { label: `整改评级 ${record.overallRating}`, tone: resolveLevelTone(record.overallRating, true) } : null,
record.primaryResponsibleDepartment
? { label: `主责 ${record.primaryResponsibleDepartment}`, tone: 'default' }
: null,
record.isOverdue ? { label: '已逾期', tone: 'rose' } : null,
record.hasEconomicLoss || record.hasExternalImpact || record.hasEmployeeAccountability
? { label: '重点后果问题', tone: 'amber' }
: null,
].filter(Boolean);
return (
<div className="space-y-2">
<div className="font-medium text-slate-900">{record.title || '(无标题)'}</div>
{tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<StatusPill key={tag.label} text={tag.label} tone={tag.tone} />
))}
</div>
) : null}
</div>
);
} }
if (fieldName === 'rectification_progress') { if (fieldName === 'rectification_progress') {
...@@ -470,11 +557,21 @@ function FieldValuePreview({ column, record }) { ...@@ -470,11 +557,21 @@ function FieldValuePreview({ column, record }) {
); );
} }
function DepartmentDetailField({ label, children }) {
return (
<div className="min-w-0">
<div className="text-xs font-medium text-slate-400">{label}</div>
<div className="mt-1.5 break-words text-sm text-slate-800">{children || '-'}</div>
</div>
);
}
export default function ProblemRectificationListPage() { export default function ProblemRectificationListPage() {
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [recordsLoading, setRecordsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
...@@ -496,16 +593,26 @@ export default function ProblemRectificationListPage() { ...@@ -496,16 +593,26 @@ export default function ProblemRectificationListPage() {
const [formValues, setFormValues] = useState({}); const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]); const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]); const [auditorUsers, setAuditorUsers] = useState([]);
const [departmentAssignments, setDepartmentAssignments] = useState([]);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE);
const [pagination, setPagination] = useState({
page: 0,
pageSize: DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
totalRecords: 0,
totalPages: 1,
});
const [gridViewCounts, setGridViewCounts] = useState({});
const [departmentUserLabels, setDepartmentUserLabels] = useState({});
const reloadList = useCallback(async () => { const reloadResources = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
setSuccessMessage(''); setSuccessMessage('');
try { try {
const resources = await loadProblemRectificationListResources(); const resources = await loadProblemRectificationListResources();
setRecords(resources.records || []);
setSubject(resources.subject || null); setSubject(resources.subject || null);
setViews(resources.views || []); setViews(resources.views || []);
} catch (requestError) { } catch (requestError) {
...@@ -519,8 +626,48 @@ export default function ProblemRectificationListPage() { ...@@ -519,8 +626,48 @@ export default function ProblemRectificationListPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
reloadList(); reloadResources();
}, [reloadList]); }, [reloadResources]);
useEffect(() => {
const userIds = Array.from(
new Set(
records.flatMap((record) =>
(record.departmentItems || []).flatMap((item) => [item.editor, item.auditor]),
).filter(Boolean),
),
);
if (userIds.length === 0) {
setDepartmentUserLabels({});
return;
}
let cancelled = false;
queryUsers(0, 1000, { _id: { $in: userIds } })
.then((result) => {
if (cancelled) {
return;
}
setDepartmentUserLabels(
Object.fromEntries(
(result?.users || []).map((user) => [
user._id,
user.display_name || user.username || user._id,
]),
),
);
})
.catch(() => {
if (!cancelled) {
setDepartmentUserLabels({});
}
});
return () => {
cancelled = true;
};
}, [records]);
const gridViews = useMemo(() => buildGridViews(views), [views]); const gridViews = useMemo(() => buildGridViews(views), [views]);
...@@ -541,16 +688,82 @@ export default function ProblemRectificationListPage() { ...@@ -541,16 +688,82 @@ export default function ProblemRectificationListPage() {
[gridViews, activeViewName], [gridViews, activeViewName],
); );
const reloadList = useCallback(
async (overrides = {}) => {
if (!subject || !activeGridView) {
return;
}
const nextPage = overrides.page ?? page;
const nextPageSize = overrides.pageSize ?? pageSize;
const nextFilters = overrides.filters ?? filters;
setRecordsLoading(true);
setError('');
if (!overrides.keepSuccessMessage) {
setSuccessMessage('');
}
try {
const [pageResult, countMap] = await Promise.all([
loadProblemRectificationListData({
page: nextPage,
pageSize: nextPageSize,
filters: nextFilters,
gridView: activeGridView,
subject,
}),
loadProblemRectificationViewCountMap({
filters: nextFilters,
views: gridViews,
subject,
}),
]);
const nextPagination = pageResult.pagination || {
page: nextPage,
pageSize: nextPageSize,
totalRecords: pageResult.records?.length || 0,
totalPages: 1,
};
setRecords(pageResult.records || []);
setPagination(nextPagination);
setGridViewCounts(countMap || {});
if (
nextPagination.totalRecords > 0 &&
nextPagination.page >= nextPagination.totalPages &&
nextPagination.totalPages > 0
) {
setPage(nextPagination.totalPages - 1);
}
} catch (requestError) {
setRecords([]);
setGridViewCounts({});
setPagination({
page: nextPage,
pageSize: nextPageSize,
totalRecords: 0,
totalPages: 1,
});
setError(requestError?.message || '问题整改列表加载失败,请稍后重试。');
} finally {
setRecordsLoading(false);
}
},
[activeGridView, filters, gridViews, page, pageSize, subject],
);
useEffect(() => {
reloadList();
}, [reloadList]);
const configuredView = useMemo( const configuredView = useMemo(
() => buildProblemRectificationConfiguredListView(records, filters, activeGridView, subject), () => buildProblemRectificationConfiguredListView(records, filters, activeGridView, subject),
[records, filters, activeGridView, subject], [records, filters, activeGridView, subject],
); );
const gridViewCounts = useMemo(
() => buildGridViewCountMap(records, filters, gridViews, subject),
[records, filters, gridViews, subject],
);
const createFields = useMemo( const createFields = useMemo(
() => buildProblemRectificationCreateFormFields(activeGridView, subject), () => buildProblemRectificationCreateFormFields(activeGridView, subject),
[activeGridView, subject], [activeGridView, subject],
...@@ -578,23 +791,24 @@ export default function ProblemRectificationListPage() { ...@@ -578,23 +791,24 @@ export default function ProblemRectificationListPage() {
setSelectedIds((current) => current.filter((id) => visibleIds.has(id))); setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [filteredRecords]); }, [filteredRecords]);
const fixedCount = filteredRecords.filter(
(item) => item.progress === '已整改' || item.progress === '已优化',
).length;
const highSeverityCount = filteredRecords.filter((item) => item.severityLevel === '高').length;
const consequenceCount = filteredRecords.filter(
(item) => item.hasEconomicLoss || item.hasExternalImpact || item.hasEmployeeAccountability,
).length;
const repeatedOrHistoricalCount = filteredRecords.filter(
(item) => item.isHistoricalProblem || item.isRepeatedProblem,
).length;
const overdueCount = filteredRecords.filter((item) => item.isOverdue).length;
const lowRatingCount = filteredRecords.filter((item) => item.overallRating === '低').length;
const pendingAuditCount = filteredRecords.filter(
(item) => item.auditStatus && item.auditStatus !== '已审批',
).length;
const quickSelectEnabled = activeGridView?.settings?.quickSelect !== false; const quickSelectEnabled = activeGridView?.settings?.quickSelect !== false;
const totalMatchingRecords = pagination.totalRecords || 0;
const isDepartmentPushMode = showPushModal && selectedRecords.length === 1;
useEffect(() => {
if (!showPushModal) {
return;
}
if (selectedRecords.length === 1) {
setDepartmentAssignments(buildDepartmentAssignmentsFromRecord(selectedRecords[0].raw || selectedRecords[0]));
setFillerUsers([]);
setAuditorUsers([]);
return;
}
setDepartmentAssignments([]);
}, [showPushModal, selectedRecords]);
const handleCreate = async () => { const handleCreate = async () => {
if ( if (
...@@ -613,7 +827,8 @@ export default function ProblemRectificationListPage() { ...@@ -613,7 +827,8 @@ export default function ProblemRectificationListPage() {
await createRecord(payload); await createRecord(payload);
setShowCreateModal(false); setShowCreateModal(false);
setFormValues(initialCreateValues); setFormValues(initialCreateValues);
await reloadList(); setPage(0);
await reloadList({ page: 0 });
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '新增问题整改记录失败,请稍后重试。'); setError(requestError?.message || '新增问题整改记录失败,请稍后重试。');
} finally { } finally {
...@@ -681,7 +896,8 @@ export default function ProblemRectificationListPage() { ...@@ -681,7 +896,8 @@ export default function ProblemRectificationListPage() {
await cloneRecord(recordName); await cloneRecord(recordName);
const title = cloneTargetRecord.title || cloneTargetRecord.projectName || cloneTargetRecord.name || '当前记录'; const title = cloneTargetRecord.title || cloneTargetRecord.projectName || cloneTargetRecord.name || '当前记录';
setCloneTargetRecord(null); setCloneTargetRecord(null);
await reloadList(); setPage(0);
await reloadList({ page: 0 });
setSuccessMessage(`已复制“${title}”,新记录已加入当前列表。`); setSuccessMessage(`已复制“${title}”,新记录已加入当前列表。`);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '复制问题整改记录失败,请稍后重试。'); setError(requestError?.message || '复制问题整改记录失败,请稍后重试。');
...@@ -704,47 +920,123 @@ export default function ProblemRectificationListPage() { ...@@ -704,47 +920,123 @@ export default function ProblemRectificationListPage() {
return result?.users || []; return result?.users || [];
}, []); }, []);
const updateDepartmentAssignment = (itemId, patch) => {
setDepartmentAssignments((current) =>
current.map((item) =>
item.item_id === itemId
? {
...item,
...patch,
}
: item,
),
);
};
const handlePush = async () => { const handlePush = async () => {
const fillerIds = fillerUsers.map((user) => user._id).filter(Boolean); const fillerIds = fillerUsers.map((user) => user._id).filter(Boolean);
const auditorIds = auditorUsers.map((user) => user._id).filter(Boolean); const auditorIds = auditorUsers.map((user) => user._id).filter(Boolean);
const isDepartmentFlow = selectedRecords.length === 1;
if (selectedRecords.length === 0) { if (selectedRecords.length === 0) {
setError('请先勾选需要推送的问题整改记录。'); setError('请先勾选需要推送的问题整改记录。');
return; return;
} }
if (fillerIds.length === 0 || auditorIds.length === 0) { if (isDepartmentFlow) {
setError('请先选择填报人和复核人。'); const hasMissingDepartmentUser = departmentAssignments.some(
return; (item) => !item.editor?._id || !item.auditor?._id,
);
if (departmentAssignments.length === 0 || hasMissingDepartmentUser) {
setError('请为每个责任部门选择 1 个填报人和 1 个复核人。');
return;
}
} else {
if (fillerIds.length !== 1 || auditorIds.length !== 1) {
setError('多选问题整改时只能选择 1 个填报人和 1 个复核人。');
return;
}
} }
setActionLoading(true); setActionLoading(true);
setError(''); setError('');
try { try {
const fillerProfiles = await Promise.all(fillerIds.map((id) => loadUser(id)));
const currentUserInfo = JSON.parse(window.localStorage.getItem('current_user') || '{}') || {}; const currentUserInfo = JSON.parse(window.localStorage.getItem('current_user') || '{}') || {};
const selectedRecordIds = selectedRecords.map((item) => item.id);
const nextFillerIds = isDepartmentFlow
? Array.from(new Set(departmentAssignments.map((item) => item.editor?._id).filter(Boolean)))
: fillerIds;
const nextAuditorIds = isDepartmentFlow
? Array.from(new Set(departmentAssignments.map((item) => item.auditor?._id).filter(Boolean)))
: auditorIds;
const messageRecord = await createRecord({ const messageRecord = await createRecord({
subject: 'message_reference_id', subject: 'message_reference_id',
title: `推送问题整改表填写 ${dayjs().format('YYYY-MM-DD HH:mm')}`, title: `推送问题整改表填写 ${dayjs().format('YYYY-MM-DD HH:mm')}`,
metadata: { metadata: {
sender: currentUserInfo?._id || currentUserInfo?.id || '', sender: currentUserInfo?._id || currentUserInfo?.id || '',
editor: fillerIds, editor: nextFillerIds,
auditor: auditorIds, auditor: nextAuditorIds,
restriction_key: '_id', restriction_key: '_id',
restriction_value: selectedRecords.map((item) => item.id), restriction_value: selectedRecordIds,
reference_record_id: selectedRecords.map((item) => item.id), reference_record_id: selectedRecordIds,
view_name: activeGridView?.name || '', view_name: activeGridView?.name || '',
subject_name: PROBLEM_RECTIFICATION_SUBJECT, subject_name: PROBLEM_RECTIFICATION_SUBJECT,
editor_status: '未完成', flow_mode: isDepartmentFlow ? FLOW_MODE_DEPARTMENT : FLOW_MODE_BATCH,
auditor_status: '未完成', department_item_ids: isDepartmentFlow ? departmentAssignments.map((item) => item.item_id) : [],
sender_status: '未完成', editor_status: EDITOR_STATUS_PENDING,
auditor_status: EDITOR_STATUS_PENDING,
sender_status: EDITOR_STATUS_PENDING,
}, },
}); });
const returnUrl = getProblemRectificationFlowTableUrl(messageRecord.name);
const progressIdEditor = []; const progressIdEditor = [];
for (const fillerProfile of fillerProfiles) {
if (isDepartmentFlow) {
const departmentItems = buildDepartmentRectificationItems(
selectedRecords[0].raw || selectedRecords[0],
departmentAssignments,
currentUserInfo,
);
for (const item of departmentItems) {
const fillerProfile = item.editor ? await loadUser(item.editor) : null;
const oaProcessResult = await executeAction('oa_notice:createProgress', {
title: `${dayjs().format('YYYY-MM-DD')} 填写问题整改表 - ${item.responsible_department}`,
classify: '流程待办',
contentType: '流程待办',
receiveUserId: fillerProfile?._id || item.editor,
receiveUserName: fillerProfile?.username || item.editor,
receiveDisplayName: fillerProfile?.display_name || fillerProfile?.username || item.editor,
sendUserId: currentUserInfo?._id || currentUserInfo?.id || '',
sendUserName: currentUserInfo?.username || '',
sendDisplayName: currentUserInfo?.display_name || '',
returnUrl,
});
item.progress_id_editor = oaProcessResult?.data?.taskId || '';
progressIdEditor.push(item.progress_id_editor);
}
await batchUpdateRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: {
_id: selectedRecords[0].id,
},
data: buildDepartmentParentBatchUpdateData(departmentItems),
});
const savedRecord = await loadRecord(selectedRecords[0].id);
const savedItems = normalizeDepartmentRectificationItems(savedRecord);
if (savedItems.length !== departmentItems.length) {
throw new Error(
`部门整改明细写入失败:预期 ${departmentItems.length} 条,后台实际返回 ${savedItems.length} 条。`,
);
}
} else {
const fillerProfile = await loadUser(nextFillerIds[0]);
const oaProcessResult = await executeAction('oa_notice:createProgress', { const oaProcessResult = await executeAction('oa_notice:createProgress', {
title: `${dayjs().format('YYYY-MM-DD')} 填写问题整改表`, title: `${dayjs().format('YYYY-MM-DD')} 填写问题整改表`,
classify: '流程待办', classify: '流程待办',
...@@ -755,9 +1047,23 @@ export default function ProblemRectificationListPage() { ...@@ -755,9 +1047,23 @@ export default function ProblemRectificationListPage() {
sendUserId: currentUserInfo?._id || currentUserInfo?.id || '', sendUserId: currentUserInfo?._id || currentUserInfo?.id || '',
sendUserName: currentUserInfo?.username || '', sendUserName: currentUserInfo?.username || '',
sendDisplayName: currentUserInfo?.display_name || '', sendDisplayName: currentUserInfo?.display_name || '',
returnUrl: `${window.location.origin}/problem_rectification_form?id=${messageRecord.name}`, returnUrl,
}); });
progressIdEditor.push(oaProcessResult?.data?.taskId); progressIdEditor.push(oaProcessResult?.data?.taskId);
await batchUpdateRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: {
_id: { $in: selectedRecordIds },
},
data: {
'metadata.fill_in_the_people': nextFillerIds,
'metadata.review_people': nextAuditorIds,
'metadata.audit_status': AUDIT_STATUS_PENDING,
'metadata.final_audit_status': AUDIT_STATUS_PENDING,
[`metadata.${DEPARTMENT_RECTIFICATION_FIELD}`]: [],
},
});
} }
await updateRecord(messageRecord._id || messageRecord.name, { await updateRecord(messageRecord._id || messageRecord.name, {
...@@ -766,22 +1072,12 @@ export default function ProblemRectificationListPage() { ...@@ -766,22 +1072,12 @@ export default function ProblemRectificationListPage() {
}, },
}); });
await batchUpdateRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: {
_id: { $in: selectedRecords.map((item) => item.id) },
},
data: {
'metadata.fill_in_the_people': fillerIds,
},
});
const noticeResult = await createNotice({ const noticeResult = await createNotice({
title: '推送问题整改表填写', title: '推送问题整改表填写',
content: `请点击链接填写问题整改表:${window.location.origin}/problem_rectification_form?id=${messageRecord.name}`, content: `请点击链接填写问题整改表:${returnUrl}`,
type: 'internal', type: 'internal',
from: currentUserInfo?._id || currentUserInfo?.id || '', from: currentUserInfo?._id || currentUserInfo?.id || '',
to: fillerIds, to: nextFillerIds,
settings: {}, settings: {},
}); });
...@@ -790,8 +1086,14 @@ export default function ProblemRectificationListPage() { ...@@ -790,8 +1086,14 @@ export default function ProblemRectificationListPage() {
setShowPushModal(false); setShowPushModal(false);
setFillerUsers([]); setFillerUsers([]);
setAuditorUsers([]); setAuditorUsers([]);
setDepartmentAssignments([]);
setSelectedIds([]); setSelectedIds([]);
await reloadList(); await reloadList();
setSuccessMessage(
isDepartmentFlow
? `推送成功,已生成 ${departmentAssignments.length} 条部门整改明细,可从记录标题右侧展开查看。`
: `推送成功,已将 ${selectedRecords.length} 条问题发送给填写人。`,
);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '推送问题整改表失败,请稍后重试。'); setError(requestError?.message || '推送问题整改表失败,请稍后重试。');
} finally { } finally {
...@@ -799,61 +1101,9 @@ export default function ProblemRectificationListPage() { ...@@ -799,61 +1101,9 @@ export default function ProblemRectificationListPage() {
} }
}; };
const summaryItems = [
{
label: '当前视图记录数',
value: filteredRecords.length,
hint: activeGridView?.title ? `${activeGridView.title} 视图` : '筛选结果',
tone: 'rose',
},
{
label: '高严重程度',
value: highSeverityCount,
hint: '问题综合严重程度 = 高',
tone: 'rose',
},
{
label: '重点后果问题',
value: consequenceCount,
hint: '含经济损失 / 外部影响 / 员工问责',
tone: 'amber',
},
{
label: '往期或重复问题',
value: repeatedOrHistoricalCount,
hint: '往期发现或再次出现的问题',
tone: 'amber',
},
{
label: '已逾期未完成',
value: overdueCount,
hint: '预计整改完成时间已到且未闭环',
tone: 'rose',
},
{
label: '整改评级偏低',
value: lowRatingCount,
hint: '综合整改评定等级 = 低',
tone: 'rose',
},
{
label: '已整改 / 已优化',
value: fixedCount,
hint: '按当前视图与筛选统计',
tone: 'emerald',
},
{
label: '待复核项',
value: pendingAuditCount,
hint: '审批状态未通过的记录',
tone: 'amber',
},
];
return ( return (
<> <>
<RecordWorkbench <RecordWorkbench
summaryItems={summaryItems}
views={gridViews.map((view) => ({ views={gridViews.map((view) => ({
...view, ...view,
count: gridViewCounts[view.name] || 0, count: gridViewCounts[view.name] || 0,
...@@ -861,6 +1111,7 @@ export default function ProblemRectificationListPage() { ...@@ -861,6 +1111,7 @@ export default function ProblemRectificationListPage() {
activeViewName={activeGridView?.name || ''} activeViewName={activeGridView?.name || ''}
onViewChange={(viewName) => { onViewChange={(viewName) => {
setActiveViewName(viewName); setActiveViewName(viewName);
setPage(0);
setSelectedIds([]); setSelectedIds([]);
}} }}
filters={filters} filters={filters}
...@@ -931,8 +1182,13 @@ export default function ProblemRectificationListPage() { ...@@ -931,8 +1182,13 @@ export default function ProblemRectificationListPage() {
options: configuredView.focusOptions, options: configuredView.focusOptions,
}, },
]} ]}
onFiltersChange={(key, value) => setFilters((current) => ({ ...current, [key]: value }))} onFiltersChange={(key, value) => {
setPage(0);
setSelectedIds([]);
setFilters((current) => ({ ...current, [key]: value }));
}}
onClearFilters={() => { onClearFilters={() => {
setPage(0);
setFilters({ setFilters({
keyword: '', keyword: '',
progress: '', progress: '',
...@@ -947,13 +1203,76 @@ export default function ProblemRectificationListPage() { ...@@ -947,13 +1203,76 @@ export default function ProblemRectificationListPage() {
setSelectedIds([]); setSelectedIds([]);
}} }}
title="问题清单" title="问题清单"
description={`当前视图显示 ${filteredRecords.length} 条记录,已选中 ${selectedIds.length} 条,其中重点后果问题 ${consequenceCount} 条、逾期未完成 ${overdueCount} 条。`} description={`当前筛选共 ${totalMatchingRecords} 条记录,本页显示 ${filteredRecords.length} 条,已选中 ${selectedIds.length} 条。`}
columns={configuredView.columns || []} columns={configuredView.columns || []}
columnGroups={activeGridView?.settings?.groups || []} columnGroups={activeGridView?.settings?.groups || []}
records={filteredRecords} records={filteredRecords}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectionChange={setSelectedIds} onSelectionChange={setSelectedIds}
selectable={quickSelectEnabled} selectable={quickSelectEnabled}
showExpandColumn={false}
isRowExpandable={(record) => record.departmentItems?.length > 0}
renderExpandedRow={({ record }) => (
<div className="sticky left-4 w-[calc(100vw-8rem)] max-w-[1320px] overflow-hidden border-y border-rose-100 bg-rose-50/25">
<div className="flex items-center justify-between gap-4 border-b border-rose-100 px-5 py-3">
<div className="text-sm font-semibold text-slate-900">部门整改明细</div>
<div className="text-xs text-slate-500">共 {record.departmentItems.length} 条</div>
</div>
<div className="divide-y divide-rose-100/80">
{(record.departmentItems || []).map((item) => (
<div
key={item.item_id}
className="grid gap-x-5 gap-y-4 px-5 py-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-8"
>
<DepartmentDetailField label="责任部门">
{item.responsible_department || '-'}
</DepartmentDetailField>
<DepartmentDetailField label="填写人">
{departmentUserLabels[item.editor] || item.editor || '-'}
</DepartmentDetailField>
<DepartmentDetailField label="审批人">
{departmentUserLabels[item.auditor] || item.auditor || '-'}
</DepartmentDetailField>
<DepartmentDetailField label="整改进度">
<StatusPill
text={item.rectification_progress || '未填写'}
tone={resolveProgressTone(item.rectification_progress)}
/>
</DepartmentDetailField>
<DepartmentDetailField label="预计完成时间">
{item.the_time_of_rectification
? dayjs(item.the_time_of_rectification).format('YYYY-MM-DD')
: '-'}
</DepartmentDetailField>
<DepartmentDetailField label="审批状态">
<StatusPill
text={item.audit_status || '未审批'}
tone={resolveAuditTone(item.audit_status)}
/>
</DepartmentDetailField>
<DepartmentDetailField label="最终审批">
<StatusPill
text={item.final_audit_status || '未审批'}
tone={resolveAuditTone(item.final_audit_status)}
/>
</DepartmentDetailField>
<DepartmentDetailField label="整改附件">
{item.rectification_proof_materials?.url ? (
<a
href={item.rectification_proof_materials.url}
target="_blank"
rel="noreferrer"
className="font-medium text-sky-700 hover:text-sky-900"
>
{item.rectification_proof_materials.name || '查看附件'}
</a>
) : '未上传'}
</DepartmentDetailField>
</div>
))}
</div>
</div>
)}
toolbarActions={[ toolbarActions={[
{ {
key: 'create', key: 'create',
...@@ -1028,7 +1347,7 @@ export default function ProblemRectificationListPage() { ...@@ -1028,7 +1347,7 @@ export default function ProblemRectificationListPage() {
), ),
}} }}
states={{ states={{
loading, loading: loading || recordsLoading,
error, error,
success: successMessage, success: successMessage,
loadingTitle: '正在加载问题整改列表', loadingTitle: '正在加载问题整改列表',
...@@ -1036,7 +1355,49 @@ export default function ProblemRectificationListPage() { ...@@ -1036,7 +1355,49 @@ export default function ProblemRectificationListPage() {
emptyTitle: '当前视图暂无数据', emptyTitle: '当前视图暂无数据',
emptyDescription: '可以切换其他视图,或调整严重程度、整改评级、主责部门和重点关注筛选。', emptyDescription: '可以切换其他视图,或调整严重程度、整改评级、主责部门和重点关注筛选。',
}} }}
renderCell={({ record, column }) => <FieldValuePreview record={record} column={column} />} pagination={{
page,
pageSize,
totalRecords: totalMatchingRecords,
totalPages: pagination.totalPages,
pageSizeOptions: PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
onPageChange: (nextPage) => {
setPage(nextPage);
setSelectedIds([]);
},
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
}}
stickyTable
renderCell={({ record, column, expandable, expanded, toggleExpanded }) => {
const fieldPreview = <FieldValuePreview record={record} column={column} />;
const fieldName = getFieldNameFromPath(column.field);
if (fieldName !== 'title' || !expandable) {
return fieldPreview;
}
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{fieldPreview}
<Button
plain
className="text-rose-700"
onClick={toggleExpanded}
aria-expanded={expanded}
>
<ChevronRightIcon
data-slot="icon"
className={clsx('transition-transform', expanded && 'rotate-90')}
/>
{expanded ? '收起明细' : `${record.departmentItems.length} 条部门明细`}
</Button>
</div>
);
}}
/> />
<Dialog open={showCreateModal} onClose={handleCloseCreateModal} size="5xl"> <Dialog open={showCreateModal} onClose={handleCloseCreateModal} size="5xl">
...@@ -1102,31 +1463,83 @@ export default function ProblemRectificationListPage() { ...@@ -1102,31 +1463,83 @@ export default function ProblemRectificationListPage() {
setShowPushModal(false); setShowPushModal(false);
setFillerUsers([]); setFillerUsers([]);
setAuditorUsers([]); setAuditorUsers([]);
setDepartmentAssignments([]);
}} }}
size="4xl" size={isDepartmentPushMode ? '5xl' : '4xl'}
> >
<DialogTitle>推送问题整改表</DialogTitle> <DialogTitle>推送问题整改表</DialogTitle>
<DialogDescription> <DialogDescription>
已从视图 <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs">{activeGridView?.title || activeGridView?.name || '-'}</code>{' '} 已从视图 <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs">{activeGridView?.title || activeGridView?.name || '-'}</code>{' '}
中选择 {selectedRecords.length} 条记录,请为本次流程配置填报人和复核人。 中选择 {selectedRecords.length} 条记录,
{isDepartmentPushMode ? '请按责任部门分别配置填报人和复核人。' : '多选推送只允许配置 1 个填报人和 1 个复核人。'}
</DialogDescription> </DialogDescription>
<DialogBody> <DialogBody>
<div className="grid gap-6 md:grid-cols-2"> {isDepartmentPushMode ? (
<RemoteUserSelect <div className="overflow-hidden rounded-[24px] border border-zinc-200 bg-white">
label="填报人" <Table bleed dense className="[--gutter:--spacing(4)]">
value={fillerUsers} <TableHead>
onChange={setFillerUsers} <TableRow className="bg-zinc-50">
onSearch={handleSearchUsers} <TableHeader>责任部门</TableHeader>
placeholder="搜索填报人用户名或显示名" <TableHeader>填报人</TableHeader>
/> <TableHeader>复核人</TableHeader>
<RemoteUserSelect </TableRow>
label="复核人" </TableHead>
value={auditorUsers} <TableBody>
onChange={setAuditorUsers} {departmentAssignments.map((assignment) => (
onSearch={handleSearchUsers} <TableRow key={assignment.item_id}>
placeholder="搜索复核人用户名或显示名" <TableCell className="w-56 align-top">
/> <div className="font-medium text-slate-900">{assignment.responsible_department}</div>
</div> <div className="mt-1 text-xs text-slate-500">将生成 1 条部门明细</div>
</TableCell>
<TableCell className="min-w-72 align-top">
<RemoteUserSelect
label="填报人"
value={assignment.editor ? [assignment.editor] : []}
onChange={(users) =>
updateDepartmentAssignment(assignment.item_id, {
editor: users[users.length - 1] || null,
})
}
onSearch={handleSearchUsers}
placeholder="搜索填报人"
/>
</TableCell>
<TableCell className="min-w-72 align-top">
<RemoteUserSelect
label="复核人"
value={assignment.auditor ? [assignment.auditor] : []}
onChange={(users) =>
updateDepartmentAssignment(assignment.item_id, {
auditor: users[users.length - 1] || null,
})
}
onSearch={handleSearchUsers}
placeholder="搜索复核人"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
<RemoteUserSelect
label="填报人"
value={fillerUsers}
onChange={(users) => setFillerUsers(users.slice(-1))}
onSearch={handleSearchUsers}
placeholder="搜索填报人用户名或显示名"
/>
<RemoteUserSelect
label="复核人"
value={auditorUsers}
onChange={(users) => setAuditorUsers(users.slice(-1))}
onSearch={handleSearchUsers}
placeholder="搜索复核人用户名或显示名"
/>
</div>
)}
<div className="mt-6 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4"> <div className="mt-6 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div className="text-sm font-medium text-slate-900">本次推送记录</div> <div className="text-sm font-medium text-slate-900">本次推送记录</div>
...@@ -1149,6 +1562,7 @@ export default function ProblemRectificationListPage() { ...@@ -1149,6 +1562,7 @@ export default function ProblemRectificationListPage() {
setShowPushModal(false); setShowPushModal(false);
setFillerUsers([]); setFillerUsers([]);
setAuditorUsers([]); setAuditorUsers([]);
setDepartmentAssignments([]);
}} }}
> >
取消 取消
......
...@@ -73,6 +73,10 @@ ...@@ -73,6 +73,10 @@
- `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器 - `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作 - 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、筛选条件、关键词和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size`
- 主列表支持每页 `10 / 20 / 50 / 100` 条,切换视图、修改筛选或清空筛选时回到第 1 页;表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围
- 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数
- 主列表滚动到表格区域后,表格工作台吸顶展示;标题栏、操作区、列分组和分页固定在工作台内,数据表格区域独立滚动,表头列名在数据区域内继续吸顶,折叠 / 展开列分组时保留当前表格滚动位置
- 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架 - 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架
- 详情侧栏顶部不展示记录标题、状态和摘要卡片,只保留详情视图切换与全屏 / 关闭操作,字段主体作为主要阅读区域 - 详情侧栏顶部不展示记录标题、状态和摘要卡片,只保留详情视图切换与全屏 / 关闭操作,字段主体作为主要阅读区域
- 详情侧栏内 `reference` 类型字段只读态统一展示为关联明细表:按目标 Subject 的 `title` 与全部 metadata 字段展开,字段值按各自类型格式化;编辑态仍使用引用选择器,保存时只写入关联记录 `_id` 或 `_id[]` - 详情侧栏内 `reference` 类型字段只读态统一展示为关联明细表:按目标 Subject 的 `title` 与全部 metadata 字段展开,字段值按各自类型格式化;编辑态仍使用引用选择器,保存时只写入关联记录 `_id` 或 `_id[]`
...@@ -134,7 +138,7 @@ ...@@ -134,7 +138,7 @@
### 4.3 流程辅助对象 ### 4.3 流程辅助对象
- `message_reference_id`:旧应用中用于承载“推送填写、复核、最终审核”状态的消息流程主单 - `message_reference_id`(信息关联id):旧应用中用于承载“推送填写、复核、最终审核”状态的消息流程主单
新项目如继续兼容旧链接 `/problem_rectification_form?id=...`,则仍需读取该对象。 新项目如继续兼容旧链接 `/problem_rectification_form?id=...`,则仍需读取该对象。
...@@ -173,7 +177,7 @@ ...@@ -173,7 +177,7 @@
### 5.4 推送问题整改表 ### 5.4 推送问题整改表
- 从已选问题记录中选择填报人和审核人 - 从已选问题记录中选择填报人和审核人
- 创建一条 `message_reference_id` 流程主单 - 创建一条 `message_reference_id`(信息关联id)流程主单
- 记录被推送问题的 `_id` 列表到 `restriction_value` - 记录被推送问题的 `_id` 列表到 `restriction_value`
- 将选中问题的 `metadata.fill_in_the_people` 批量更新为填报人 - 将选中问题的 `metadata.fill_in_the_people` 批量更新为填报人
- 给填报人发送内部通知 - 给填报人发送内部通知
...@@ -222,6 +226,7 @@ ...@@ -222,6 +226,7 @@
替代实现约定: 替代实现约定:
- 列表页保留表格浏览、搜索、筛选、批量选择 - 列表页保留表格浏览、搜索、筛选、批量选择
- 列表页采用分页加载,默认每页 20 条,可切换为 10、50、100;分页、筛选、视图切换均由服务端查询结果驱动
- 字段编辑统一进入右侧详情抽屉 - 字段编辑统一进入右侧详情抽屉
- 长文本、附件、状态类字段在详情抽屉中直接维护 - 长文本、附件、状态类字段在详情抽屉中直接维护
- 详情抽屉内字段顺序与显示权限跟随 `detail view` 配置 - 详情抽屉内字段顺序与显示权限跟随 `detail view` 配置
...@@ -230,7 +235,7 @@ ...@@ -230,7 +235,7 @@
## 6. 流程角色 ## 6. 流程角色
- 发起人:通常为问题整改列表操作人,负责发起填报和最终审核 - 发起人:通常为问题整改列表操作人,负责发起填报和最终审核;可编辑本次推送关联的父问题记录以及全部部门整改明细业务字段
- 填报人:负责补充整改情况、附件、预计完成时间等 - 填报人:负责补充整改情况、附件、预计完成时间等
- 复核人:负责复核填报内容,决定通过或驳回 - 复核人:负责复核填报内容,决定通过或驳回
- 最终审核人:旧流程中通常仍由发起人承担最终审核 - 最终审核人:旧流程中通常仍由发起人承担最终审核
...@@ -250,12 +255,12 @@ ...@@ -250,12 +255,12 @@
- 检查实施时间 - 检查实施时间
- 检查报告出具日 - 检查报告出具日
- 问题简述 - 问题简述
- 问题发现情况
- 责任部门 - 责任部门
- 问题来源 - 问题来源
- 问题类型 - 问题类型
- 反馈单位 - 反馈单位
- 问题涉及业务领域 - 问题涉及业务领域
- 若线上 Subject 配置存在 `problem_discovery_situation`,可兼容展示“问题发现情况”,但当前主对象字段定义不再将其作为固定字段
3. 根据 2026 年整改驾驶舱要求,补录扩展治理字段: 3. 根据 2026 年整改驾驶舱要求,补录扩展治理字段:
- 问题后果:是否造成经济损失、对应经济损失、是否造成外部影响、处罚机构、是否涉及员工问责、问责人数 - 问题后果:是否造成经济损失、对应经济损失、是否造成外部影响、处罚机构、是否涉及员工问责、问责人数
- 问题画像:问题综合严重程度、问题一级至四级分类、问题原因 - 问题画像:问题综合严重程度、问题一级至四级分类、问题原因
...@@ -267,18 +272,17 @@ ...@@ -267,18 +272,17 @@
推荐新交互: 推荐新交互:
- 工作台筛选区增加严重程度、整改评级、主责部门、问题来源、问题原因、重点关注等维度 - 工作台筛选区增加严重程度、整改评级、主责部门、问题来源、问题原因、重点关注等维度
- 列表标题列增加风险标签,优先暴露“高严重程度”“重点后果问题”“已逾期”等重点状态 - 列表标题列只显示标题文本,不在标题下方追加严重程度、整改评级、主责部门、逾期或重点后果标签;相关信息由对应业务列展示
- 新增弹窗除基础字段外,还应补充严重程度、分类、原因和整改质效字段,避免驾驶舱后续无数据可用 - 新增弹窗除基础字段外,还应补充严重程度、分类、原因和整改质效字段,避免驾驶舱后续无数据可用
- 摘要卡优先展示高严重程度、重点后果问题、往期/重复问题、逾期未完成、整改评级偏低等指标 - 新增弹窗中 `select` 且 `multiple=true` 的字段使用 Headless UI 下拉多选,不使用原生多行选择框;选项仍从 Subject `settings.options` 读取,提交时保存选项值数组
### 7.2 发起整改填报 ### 7.2 发起整改填报
1. 在问题整改列表中勾选一条或多条问题 1. 在问题整改列表中勾选一条或多条问题
2. 点击“推送问题整改表” 2. 点击“推送问题整改表”
3. 通过用户搜索选择: 3. 如果只选择 1 条问题,系统按该问题的 `metadata.responsible_department` 生成部门配置行;每个责任部门必须选择 1 个填写人和 1 个审批人
- 填报人 `fill_in_the_people` 4. 如果选择多条问题,不拆分责任部门;本次推送只允许选择 1 个填写人和 1 个审批人,并批量应用到选中问题
- 复核人 `review_people` 5. 创建 `message_reference_id`(信息关联id)流程主单,写入:
4. 创建 `message_reference_id` 流程主单,写入:
- `sender` - `sender`
- `editor` - `editor`
- `auditor` - `auditor`
...@@ -287,83 +291,88 @@ ...@@ -287,83 +291,88 @@
- `reference_record_id = 选中问题记录 ID 列表` - `reference_record_id = 选中问题记录 ID 列表`
- `subject_name = "problem_rectification"` - `subject_name = "problem_rectification"`
- `view_name = 当前筛选视图名` - `view_name = 当前筛选视图名`
- `flow_mode = "department"` 或 `flow_mode = "batch"`
- `department_item_ids = 本次部门明细 item_id 列表`
- `editor_status = "未完成"` - `editor_status = "未完成"`
- `auditor_status = "未完成"` - `auditor_status = "未完成"`
- `sender_status = "未完成"` - `sender_status = "未完成"`
5. 批量更新选中问题的 `metadata.fill_in_the_people` 6. 单条部门推送时,回写该问题的 `metadata.department_rectification_items`;每个部门明细独立保存责任部门、填写人、审批人、整改字段、审批状态和待办 ID
6. 发送内部通知与 OA 待办 - 写入后重新读取父问题并校验部门明细数量;后台未返回预期明细时,本次推送显示失败,不展示成功提示
7. 多选简单推送时,批量更新选中问题的 `metadata.fill_in_the_people`、`metadata.review_people`
8. 发送内部通知与 OA 待办,链接指向 `/problem_rectification_new/problem_rectification_flow_table?id=<message_record_name>`
推荐新交互: 推荐新交互:
- 顶部批量操作栏出现“推送整改表” - 顶部批量操作栏出现“推送整改表”
- 点击后打开“选择填报人 / 复核人”弹窗 - 点击后打开推送弹窗;单条问题展示部门配置表格,多选问题展示单填写人 / 单复核人配置
- 弹窗底部明确展示本次选中记录数量 - 弹窗底部明确展示本次选中记录数量和生成的部门明细数量
- 成功后列表即时刷新,并给出已推送结果提示 - 成功后列表即时刷新,并给出已推送结果提示
### 7.3 填报人补录整改内容 ### 7.3 填报人补录整改内容
1. 填报人通过消息链接进入整改填写页 1. 填报人通过消息链接进入新表格处理页 `/problem_rectification_new/problem_rectification_flow_table`
2. 页面只展示本次流程绑定的记录范围 2. 页面展示本次流程绑定的所有部门明细行;参与人可查看全部行,填写人只能编辑自己负责的行,流程推送人可以编辑全部部门明细行。表格同时只读展示父问题的 `problem_brief`(问题简述)和 `related_issue_domain`(涉及业务领域);填写人和审批人字段保存用户 `_id`,表格展示用户姓名,姓名查询不到时才回退显示 ID
3. 填报人可编辑: 3. 填报人可在表格行内编辑:
- `rectification_progress` - `rectification_progress`
- `rectification_situation` - `rectification_situation`
- `the_time_of_rectification` - `the_time_of_rectification`
- `remark`
- `rectification_proof_materials` - `rectification_proof_materials`
4. 如有新增问题,也可能被补充进本次流程绑定范围 4. 填写人送审后,该行填写业务字段锁定;只有审批驳回后,填写人恢复编辑
5. 填写完成后点击“送审” 5. 审批驳回后,原审批人也可以编辑该行整改业务字段,但不能代替填写人点击“送审”;待填写人重新送审后,审批人恢复审批操作
6. 流程推送人可以从问题列打开父问题详情抽屉,编辑本次推送关联的 `problem_rectification` 主记录;填写人和审批人只能查看父问题记录。部门流程的父级汇总字段、部门明细对象、审批状态和流程人员字段不在详情抽屉中直接编辑
推荐新交互: 推荐新交互:
- 页面顶部显示本次流程步骤和当前身份 - 页面顶部显示本次流程步骤和当前身份
- 主体使用结构化表格 + 详情抽屉,不直接暴露底层 JSON - 主体统一使用表格,不再使用旧填写页的详情式编辑
- 长文本、附件、备注在详情抽屉内集中编辑 - 长文本、附件、预计完成时间和整改进度均在表格行内维护
- “送审”按钮固定在页面顶部操作区,避免滚动后找不到 - “保存”“送审”“审批通过”“驳回”“最终通过”“最终驳回”作为行级操作展示
- 推送人具有部门明细“保存”权限,但不会因此获得填写人的“送审”权限;推送人同时是该行填写人时,才按填写人身份送审
### 7.4 复核人审核 ### 7.4 复核人审核
送审后,系统应: 部门明细送审后,系统应:
1. 将 `message_reference_id.metadata.editor_status` 更新为 `已完成` 1. 将该部门明细的 `editor_status` 更新为 `已完成`
2. 创建复核人的 OA 待办 2. 创建该部门审批人的 OA 待办
3. 给复核人发送通知 3. 给该部门审批人发送通知
复核人进入页面后有两个结果: 复核人进入新表格处理页后,对自己负责的部门明细有两个结果:
1. 一键复核通过 1. 审批通过
- 批量更新所有关联问题 `metadata.audit_status = "已审批"` - 更新该部门明细 `audit_status = "已审批"`
- 回写 `metadata.review_people` - 立即创建该部门明细的最终审批待办给发起人,不等待其他部门审批完成
- 创建最终审核待办给发起人 - 写入该部门明细 `progress_id_sender`
- 更新流程主单 `auditor_status = "已完成"`、`sender_status = "未完成"`
2. 驳回 2. 驳回
- 批量更新所有关联问题 `metadata.audit_status = "已驳回"` - 更新该部门明细 `audit_status = "已驳回"`
- 重新给填报人发通知和待办 - 将该部门明细 `editor_status` 重置为 `未完成`
- 更新流程主单 `editor_status = "未完成"`、`auditor_status = "已完成"` - 允许该审批人编辑该部门明细整改业务字段;填写人同步恢复编辑,审批人不获得“送审”权限
- 重新写入 `progress_id_editor`,使流程回到填写阶段 - 重新给该部门填写人发通知和待办
复核人审批通过后,该部门明细业务字段锁定;只有审批驳回状态下,复核人拥有该行“保存”权限。填写人重新送审后,复核人的编辑权限随行重新进入审批阶段而关闭。
推荐新交互: 推荐新交互:
- 顶部提供“复核通过”“驳回”两个主操作 - 表格行内提供“审批通过”“驳回”两个操作
- 驳回时建议补充驳回原因输入框,为后续完善流程留接口 - 驳回时建议补充驳回原因输入框,为后续完善流程留接口
- 审核操作前显示本次关联问题数量和当前填报人 / 复核人信息 - 审核操作前显示当前行的责任部门、填写人、审批人和整改材料
### 7.5 发起人最终审核 ### 7.5 发起人最终审核
最终审核人进入页面后也有两个结果: 最终审核人进入新表格处理页后,按部门明细逐行最终审批:
1. 一键最终审核通过 1. 最终审核通过
- 批量更新所有关联问题 `metadata.final_audit_status = "已审批"` - 更新该部门明细 `final_audit_status = "已审批"`
- 更新流程主单 `sender_status = "已完成"` 2. 最终审核驳回
2. 驳回审批 - 更新该部门明细 `final_audit_status = "已驳回"`
- 批量更新所有关联问题 `metadata.final_audit_status = "已驳回"` - 将该部门明细 `editor_status` 重置为 `未完成`
- 重建填写人待办 - 重新通知该部门填写人处理
- 更新流程主单 `editor_status = "未完成"`、`sender_status = "已完成"`
- 重新写入 `progress_id_editor`,使流程重新回到填写阶段
推荐新交互: 推荐新交互:
- 页面头部展示“填写完成 / 复核完成 / 最终审核”三段式状态 - 表格中分开展示“审批状态”和“最终审批状态”
- 发起人在最终审核时只需要看到汇总结果和关键问题,不必再回到多维表格编辑体验 - 最终审批行级处理,不要求本次推送所有部门都先审批通过
- 发起人编辑已经送审或审批完成的父问题、部门整改内容时,不自动重置审批状态,也不自动重新生成 OA 待办
--- ---
...@@ -388,11 +397,13 @@ ...@@ -388,11 +397,13 @@
### 8.2 流程主单状态字段 ### 8.2 流程主单状态字段
`message_reference_id` 在当前项目中的约定: `message_reference_id`(信息关联id)在当前项目中的约定:
- `sender` 记录推送发起人 - `sender` 记录推送发起人
- `editor` 记录填写人列表 - `editor` 记录填写人列表
- `auditor` 记录审核人列表 - `auditor` 记录审核人列表
- `flow_mode` 记录流程模式:`department` 表示单条问题按责任部门拆分明细,`batch` 表示多选问题的单填写人 / 单审批人简单推送
- `department_item_ids` 记录本次部门流程关联的 `department_rectification_items[].item_id`
- `restriction_key` 默认固定为 `_id` - `restriction_key` 默认固定为 `_id`
- `restriction_value` 保存本次推送绑定的整改记录 `_id[]` - `restriction_value` 保存本次推送绑定的整改记录 `_id[]`
- `reference_record_id` 作为关联记录 ID 冗余字段,和 `restriction_value` 一致 - `reference_record_id` 作为关联记录 ID 冗余字段,和 `restriction_value` 一致
...@@ -402,7 +413,19 @@ ...@@ -402,7 +413,19 @@
- `auditor_status`:`未完成`、`已完成` - `auditor_status`:`未完成`、`已完成`
- `sender_status`:`未完成`、`已完成` - `sender_status`:`未完成`、`已完成`
当前新页面不再依赖旧版 `未审批`、`已驳回` 之类流程主单状态,而是通过“状态字段 + 当前 progress_id”共同判断流程走向。驳回场景通过重建 `progress_id_editor` 并将 `editor_status` 重置为 `未完成` 来回到填写阶段。 当前新页面不再依赖旧版 `未审批`、`已驳回` 之类流程主单状态,而是通过“状态字段 + 当前 progress_id”共同判断流程走向。新部门流程中,部门明细上的 `editor_status`、`audit_status`、`final_audit_status` 是事实来源;流程主单状态仅用于兼容和整体提示。驳回场景只重置对应部门明细,不影响其他部门明细。
### 8.3 部门明细汇总约定
`department_rectification_items` 存放在父问题 `metadata` 中:
- 问题整改主列表父行仍显示一条问题
- 父行的整改进度、审批状态、最终审批状态按部门明细汇总;全部一致则显示该状态,不一致则显示“部分整改”或“部分审批”
- 父行可展开显示部门明细,明细中分开展示责任部门、填写人、审批人、整改进度、预计完成时间、审批状态、最终审批状态和附件状态
- 存在部门明细的父行在标题右侧显示“N 条部门明细”入口;单部门推送同样生成 1 条明细。展开区使用贴合当前视口的响应式扁平信息网格,不嵌套横向子表格,并将填写人、审批人 ID 转为用户显示名
- 转发后的整改填写、审批和最终审批均优先更新部门明细,父字段仅做兼容汇总展示
- 新流程页中,填写人只能维护本人负责行的整改业务字段,送审后锁定,复核驳回后恢复;复核驳回状态下对应审批人可以维护该行整改业务字段,审批通过后锁定,且审批人不能点击填写人的“送审”;流程推送人可以维护本次推送关联的父问题记录和全部部门明细整改业务字段,但不能直接编辑流程状态与待办 ID
- 推送人的内容修改保持当前审批状态不变,不触发重新送审
--- ---
...@@ -595,7 +618,7 @@ ...@@ -595,7 +618,7 @@
- 基础信息 - 基础信息
`project_name`、`inspect_the_implementing_unit`、`check_the_implementation_time`、`inspection_report_date` `project_name`、`inspect_the_implementing_unit`、`check_the_implementation_time`、`inspection_report_date`
- 问题信息 - 问题信息
`problem_brief`、`problem_discovery_situation`、`problem_from`、`problem_type`、`related_issue_domain` `problem_brief`、`problem_from`、`problem_type`、`related_issue_domain`
- 责任与流转 - 责任与流转
`responsible_department`、`fill_in_the_people`、`review_people`、`audit_status`、`final_audit_status` `responsible_department`、`fill_in_the_people`、`review_people`、`audit_status`、`final_audit_status`
- 整改信息 - 整改信息
...@@ -771,7 +794,7 @@ const result = await queryUsers(0, 20, { ...@@ -771,7 +794,7 @@ const result = await queryUsers(0, 20, {
- 功能入口 - 功能入口
- 关键交互步骤 - 关键交互步骤
- 推送、填报、复核、最终审核流程 - 推送、填报、复核、最终审核流程
- `message_reference_id` 的当前字段协议与回流规则 - `message_reference_id`(信息关联id)的当前字段协议与回流规则
- 驾驶舱统计口径 - 驾驶舱统计口径
- 归档流程 - 归档流程
- 状态变化与数据约束 - 状态变化与数据约束
......
...@@ -10,7 +10,10 @@ import { ...@@ -10,7 +10,10 @@ import {
sendNotice, sendNotice,
updateRecord, updateRecord,
} from '@/services/DataService'; } from '@/services/DataService';
import { normalizeProblemRectificationRecord } from './problemRectificationListService'; import {
getProblemRectificationFormUrl,
normalizeProblemRectificationRecord,
} from './problemRectificationListService';
import { normalizeText, toArray } from '@/utils/subjectView'; import { normalizeText, toArray } from '@/utils/subjectView';
const MESSAGE_REFERENCE_SUBJECT = 'message_reference_id'; const MESSAGE_REFERENCE_SUBJECT = 'message_reference_id';
...@@ -147,11 +150,7 @@ async function sendFlowNotice({ title, content, fromUserId, toUserIds = [] }) { ...@@ -147,11 +150,7 @@ async function sendFlowNotice({ title, content, fromUserId, toUserIds = [] }) {
} }
function buildReturnUrl(messageName) { function buildReturnUrl(messageName) {
if (typeof window === 'undefined') { return getProblemRectificationFormUrl(messageName);
return `/problem_rectification_form?id=${messageName}`;
}
return `${window.location.origin}/problem_rectification_form?id=${messageName}`;
} }
function resolveCurrentStep(messageRecord) { function resolveCurrentStep(messageRecord) {
......
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
countRecords,
getSubjectView, getSubjectView,
loadSubject, loadSubject,
queryRecords, queryRecords,
...@@ -12,6 +13,7 @@ import { ...@@ -12,6 +13,7 @@ import {
formatFieldValue, formatFieldValue,
getFieldNameFromPath, getFieldNameFromPath,
getFieldValue, getFieldValue,
getSelectOptions,
isViewAccessible, isViewAccessible,
normalizeValueForSubmit, normalizeValueForSubmit,
normalizeText, normalizeText,
...@@ -20,8 +22,29 @@ import { ...@@ -20,8 +22,29 @@ import {
} from '@/utils/subjectView'; } from '@/utils/subjectView';
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification'; export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 20;
export const DEPARTMENT_RECTIFICATION_FIELD = 'department_rectification_items';
export const FLOW_MODE_DEPARTMENT = 'department';
export const FLOW_MODE_BATCH = 'batch';
export const PROBLEM_RECTIFICATION_FLOW_TABLE_PATH = '/problem_rectification_flow_table';
export const EDITOR_STATUS_PENDING = '未完成';
export const EDITOR_STATUS_COMPLETED = '已完成';
export const AUDIT_STATUS_PENDING = '未审批';
export const AUDIT_STATUS_APPROVED = '已审批';
export const AUDIT_STATUS_REJECTED = '已驳回';
const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']); const FIXED_PROGRESS_VALUES = new Set(['已整改', '已优化']);
const LEVEL_ORDER = ['高', '中', '低']; const LEVEL_ORDER = ['高', '中', '低'];
const FILTER_FIELD_PATHS = {
progress: 'metadata.rectification_progress',
auditStatus: 'metadata.audit_status',
severityLevel: 'metadata.problem_severity_level',
overallRating: 'metadata.rectification_overall_rating',
primaryResponsibleDepartment: 'metadata.primary_responsible_department',
problemFrom: 'metadata.problem_from',
rootCause: 'metadata.problem_root_cause',
};
const AFFIRMATIVE_FILTER_VALUES = ['是', '有', '涉及', 'true', '1', 'Y', 'YES', true];
const WORKBENCH_PRIORITY_CREATE_FIELD_NAMES = [ const WORKBENCH_PRIORITY_CREATE_FIELD_NAMES = [
'problem_severity_level', 'problem_severity_level',
'problem_category_level_1', 'problem_category_level_1',
...@@ -84,6 +107,190 @@ function normalizeUserLabel(value) { ...@@ -84,6 +107,190 @@ function normalizeUserLabel(value) {
); );
} }
function normalizeUserId(value) {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value.trim();
}
return String(value._id || value.id || value.name || '').trim();
}
function uniqueTruthy(values = []) {
return Array.from(new Set(values.map((item) => normalizeText(item)).filter(Boolean)));
}
function makeDepartmentItemId(record, department, index) {
const recordId = normalizeText(record?._id || record?.name || record?.id || 'record');
const departmentSlug = normalizeText(department) || `department-${index + 1}`;
const randomPart = Math.random().toString(36).slice(2, 8);
return `dept_${recordId}_${index + 1}_${departmentSlug}_${Date.now()}_${randomPart}`;
}
function summarizeTextStatus(values = [], partialText = '部分审批', emptyText = '') {
const normalizedValues = uniqueTruthy(values);
if (normalizedValues.length === 0) {
return emptyText;
}
if (normalizedValues.length === 1) {
return normalizedValues[0];
}
return partialText;
}
function summarizeDate(values = []) {
const normalizedValues = uniqueTruthy(values);
return normalizedValues.length === 1 ? normalizedValues[0] : null;
}
export function getProblemRectificationFlowTableUrl(messageName) {
const query = messageName ? `?id=${encodeURIComponent(messageName)}` : '';
const basePath = String(PROBLEM_RECTIFICATION_APP_BASE_PATH || '/')
.replace(/\/+$/, '');
const appPath = `${basePath}${PROBLEM_RECTIFICATION_FLOW_TABLE_PATH}${query}`;
if (typeof window === 'undefined') {
return appPath;
}
return `${window.location.origin}${appPath}`;
}
export function getProblemRectificationFormUrl(messageName) {
const query = messageName ? `?id=${encodeURIComponent(messageName)}` : '';
const basePath = String(PROBLEM_RECTIFICATION_APP_BASE_PATH || '/')
.replace(/\/+$/, '');
const appPath = `${basePath}/problem_rectification_form${query}`;
if (typeof window === 'undefined') {
return appPath;
}
return `${window.location.origin}${appPath}`;
}
export function normalizeDepartmentRectificationItems(record) {
const metadata = record?.metadata || record?.raw?.metadata || {};
const rawItems = metadata[DEPARTMENT_RECTIFICATION_FIELD];
let parsedItems = rawItems;
if (typeof rawItems === 'string') {
try {
parsedItems = JSON.parse(rawItems);
} catch (error) {
parsedItems = [];
}
}
return toArray(parsedItems)
.filter((item) => item && typeof item === 'object')
.map((item, index) => ({
item_id: normalizeText(item.item_id) || makeDepartmentItemId(record, item.responsible_department, index),
responsible_department: normalizeText(item.responsible_department),
editor: normalizeUserId(item.editor),
auditor: normalizeUserId(item.auditor),
rectification_progress: normalizeText(item.rectification_progress),
rectification_situation: normalizeText(item.rectification_situation),
the_time_of_rectification: item.the_time_of_rectification || null,
rectification_proof_materials: item.rectification_proof_materials || null,
editor_status: normalizeText(item.editor_status) || EDITOR_STATUS_PENDING,
audit_status: normalizeText(item.audit_status) || AUDIT_STATUS_PENDING,
final_audit_status: normalizeText(item.final_audit_status) || AUDIT_STATUS_PENDING,
progress_id_editor: item.progress_id_editor || '',
progress_id_auditor: item.progress_id_auditor || '',
progress_id_sender: item.progress_id_sender || '',
updated_by: item.updated_by || '',
updated_at: item.updated_at || '',
created_by: item.created_by || '',
created_at: item.created_at || '',
}));
}
export function buildDepartmentAssignmentsFromRecord(record) {
const metadata = record?.metadata || record?.raw?.metadata || {};
const departments = uniqueTruthy(
toArray(metadata.responsible_department).length > 0
? toArray(metadata.responsible_department)
: [metadata.primary_responsible_department || '未指定责任部门'],
);
return (departments.length > 0 ? departments : ['未指定责任部门']).map((department, index) => ({
item_id: makeDepartmentItemId(record?.raw || record, department, index),
responsible_department: department,
editor: null,
auditor: null,
}));
}
export function buildDepartmentRectificationItems(record, assignments = [], currentUser = null) {
const now = dayjs().toISOString();
const currentUserId = normalizeUserId(currentUser);
return assignments.map((assignment, index) => ({
item_id: assignment.item_id || makeDepartmentItemId(record?.raw || record, assignment.responsible_department, index),
responsible_department: normalizeText(assignment.responsible_department) || '未指定责任部门',
editor: normalizeUserId(assignment.editor),
auditor: normalizeUserId(assignment.auditor),
rectification_progress: '',
rectification_situation: '',
the_time_of_rectification: null,
rectification_proof_materials: null,
editor_status: EDITOR_STATUS_PENDING,
audit_status: AUDIT_STATUS_PENDING,
final_audit_status: AUDIT_STATUS_PENDING,
progress_id_editor: '',
progress_id_auditor: '',
progress_id_sender: '',
created_by: currentUserId,
created_at: now,
updated_by: currentUserId,
updated_at: now,
}));
}
export function buildDepartmentParentMetadataPatch(items = []) {
const normalizedItems = items.filter(Boolean);
const editorIds = uniqueTruthy(normalizedItems.map((item) => item.editor));
const auditorIds = uniqueTruthy(normalizedItems.map((item) => item.auditor));
return {
[DEPARTMENT_RECTIFICATION_FIELD]: normalizedItems,
fill_in_the_people: editorIds,
review_people: auditorIds,
rectification_progress: summarizeTextStatus(
normalizedItems.map((item) => item.rectification_progress),
'部分整改',
'',
),
audit_status: summarizeTextStatus(
normalizedItems.map((item) => item.audit_status),
'部分审批',
AUDIT_STATUS_PENDING,
),
final_audit_status: summarizeTextStatus(
normalizedItems.map((item) => item.final_audit_status),
'部分审批',
AUDIT_STATUS_PENDING,
),
the_time_of_rectification: summarizeDate(
normalizedItems.map((item) => item.the_time_of_rectification),
),
};
}
export function buildDepartmentParentBatchUpdateData(items = []) {
return Object.fromEntries(
Object.entries(buildDepartmentParentMetadataPatch(items)).map(([fieldName, value]) => [
`metadata.${fieldName}`,
value,
]),
);
}
function normalizeFlagText(value) { function normalizeFlagText(value) {
const text = normalizeText(value); const text = normalizeText(value);
return text || '否'; return text || '否';
...@@ -132,12 +339,227 @@ function findSubjectField(subject, fieldName) { ...@@ -132,12 +339,227 @@ function findSubjectField(subject, fieldName) {
return (subject?.fields || []).find((field) => field?.name === fieldName) || null; return (subject?.fields || []).find((field) => field?.name === fieldName) || null;
} }
function isEmptyFilterValue(value) {
if (value === undefined || value === null || value === '') {
return true;
}
return Array.isArray(value) && value.length === 0;
}
function compactFilterClauses(clauses = []) {
return clauses.filter((clause) => clause && Object.keys(clause).length > 0);
}
function combineFilterClauses(clauses = []) {
const nextClauses = compactFilterClauses(clauses);
if (nextClauses.length === 0) {
return {};
}
if (nextClauses.length === 1) {
return nextClauses[0];
}
return { $and: nextClauses };
}
function buildComparatorFilter(field, comparator = '$eq', value) {
if (!field) {
return null;
}
if (comparator === '$eq') {
return { [field]: value };
}
if (comparator === '$contains') {
return { [field]: { $regex: normalizeText(value), $options: 'i' } };
}
if (comparator === '$exists') {
return { [field]: { $exists: Boolean(value) } };
}
return { [field]: { [comparator]: value } };
}
function buildRestrictionsFilter(restrictions = []) {
const clauses = toArray(restrictions)
.filter((restriction) => restriction?.type === 'field' && restriction.field)
.map((restriction) =>
buildComparatorFilter(
restriction.field,
restriction.comparator || '$in',
restriction.comparator === '$in' || restriction.comparator === '$nin'
? toArray(restriction.value)
: restriction.value,
),
);
return combineFilterClauses(clauses);
}
function buildViewConditionsFilter(filters = {}) {
const rules = Array.isArray(filters?.conditions) ? filters.conditions : [];
const clauses = rules
.filter((rule) => rule?.field)
.map((rule) =>
buildComparatorFilter(
rule.field,
rule.comparator || '$eq',
rule.comparator === '$in' || rule.comparator === '$nin'
? toArray(rule.value)
: rule.value,
),
);
const nextClauses = compactFilterClauses(clauses);
if (nextClauses.length === 0) {
return {};
}
if (filters?.relationship === 'any' || filters?.relationship === 'or') {
return { $or: nextClauses };
}
return combineFilterClauses(nextClauses);
}
function buildKeywordFilter(keyword, columns = []) {
const text = normalizeText(keyword);
if (!text) {
return {};
}
const fields = Array.from(
new Set([
'title',
...columns
.map((column) => column.field)
.filter((field) => field && field !== 'title'),
]),
);
return {
$or: fields.map((field) => ({
[field]: { $regex: text, $options: 'i' },
})),
};
}
function buildFocusFlagFilter(focusFlag) {
if (focusFlag === 'consequence') {
return {
$or: [
{ 'metadata.has_economic_loss': { $in: AFFIRMATIVE_FILTER_VALUES } },
{ 'metadata.has_external_impact': { $in: AFFIRMATIVE_FILTER_VALUES } },
{ 'metadata.has_employee_accountability': { $in: AFFIRMATIVE_FILTER_VALUES } },
],
};
}
if (focusFlag === 'high_severity') {
return { 'metadata.problem_severity_level': '高' };
}
if (focusFlag === 'low_rating') {
return { 'metadata.rectification_overall_rating': '低' };
}
if (focusFlag === 'repeated_or_historical') {
return {
$or: [
{ 'metadata.is_historical_problem': { $in: AFFIRMATIVE_FILTER_VALUES } },
{ 'metadata.is_repeated_problem': { $in: AFFIRMATIVE_FILTER_VALUES } },
],
};
}
if (focusFlag === 'overdue') {
return {
'metadata.the_time_of_rectification': { $lt: dayjs().startOf('day').toISOString() },
'metadata.rectification_progress': { $nin: Array.from(FIXED_PROGRESS_VALUES) },
};
}
if (focusFlag === 'policy_or_system') {
return {
$or: [
{ 'metadata.involves_policy_revision': { $in: AFFIRMATIVE_FILTER_VALUES } },
{ 'metadata.involves_system_construction': { $in: AFFIRMATIVE_FILTER_VALUES } },
],
};
}
return {};
}
export function buildProblemRectificationQueryFilter(filters = {}, gridView, subject) {
const columns = buildViewColumnsForList(gridView, subject);
const uiFieldClauses = Object.entries(FILTER_FIELD_PATHS).map(([filterKey, fieldPath]) => {
const value = filters?.[filterKey];
if (isEmptyFilterValue(value)) {
return null;
}
return { [fieldPath]: value };
});
return combineFilterClauses([
buildRestrictionsFilter(gridView?.settings?.restrictions),
buildViewConditionsFilter(gridView?.settings?.filters),
...uiFieldClauses,
buildFocusFlagFilter(filters?.focusFlag),
buildKeywordFilter(filters?.keyword, columns),
]);
}
function buildProblemRectificationSort(gridView) {
const sortColumns = toArray(gridView?.settings?.columns)
.filter((column) => column.sort && column.field)
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
const viewSort = sortColumns.map((column) =>
column.sort === 'desc' ? `-${column.field}` : column.field,
);
return [...viewSort, '-updated_at', '-created_at', '_id'].join(' ');
}
export function normalizeProblemRectificationRecord(record) { export function normalizeProblemRectificationRecord(record) {
const metadata = record?.metadata || {}; const metadata = record?.metadata || {};
const dueDate = metadata.the_time_of_rectification const departmentItems = normalizeDepartmentRectificationItems(record);
? dayjs(metadata.the_time_of_rectification) const hasDepartmentItems = departmentItems.length > 0;
const summarizedProgress = hasDepartmentItems
? summarizeTextStatus(
departmentItems.map((item) => item.rectification_progress),
'部分整改',
'',
)
: normalizeText(metadata.rectification_progress);
const summarizedAuditStatus = hasDepartmentItems
? summarizeTextStatus(
departmentItems.map((item) => item.audit_status),
'部分审批',
AUDIT_STATUS_PENDING,
)
: normalizeText(metadata.audit_status);
const summarizedFinalAuditStatus = hasDepartmentItems
? summarizeTextStatus(
departmentItems.map((item) => item.final_audit_status),
'部分审批',
AUDIT_STATUS_PENDING,
)
: normalizeText(metadata.final_audit_status);
const summarizedDueDate = hasDepartmentItems
? summarizeDate(departmentItems.map((item) => item.the_time_of_rectification))
: metadata.the_time_of_rectification;
const dueDate = summarizedDueDate
? dayjs(summarizedDueDate)
: null; : null;
const normalizedProgress = normalizeText(metadata.rectification_progress); const normalizedProgress = summarizedProgress;
const isFixed = FIXED_PROGRESS_VALUES.has(normalizedProgress); const isFixed = FIXED_PROGRESS_VALUES.has(normalizedProgress);
const hasEconomicLoss = isAffirmative(metadata.has_economic_loss); const hasEconomicLoss = isAffirmative(metadata.has_economic_loss);
const hasExternalImpact = isAffirmative(metadata.has_external_impact); const hasExternalImpact = isAffirmative(metadata.has_external_impact);
...@@ -158,8 +580,8 @@ export function normalizeProblemRectificationRecord(record) { ...@@ -158,8 +580,8 @@ export function normalizeProblemRectificationRecord(record) {
inspectUnit: normalizeText(metadata.inspect_the_implementing_unit), inspectUnit: normalizeText(metadata.inspect_the_implementing_unit),
progress: normalizedProgress, progress: normalizedProgress,
feedbackUnit: normalizeText(metadata.feedback_unit), feedbackUnit: normalizeText(metadata.feedback_unit),
auditStatus: normalizeText(metadata.audit_status), auditStatus: summarizedAuditStatus,
finalAuditStatus: normalizeText(metadata.final_audit_status), finalAuditStatus: summarizedFinalAuditStatus,
problemType: normalizeText(metadata.problem_type), problemType: normalizeText(metadata.problem_type),
problemFrom: normalizeText(metadata.problem_from), problemFrom: normalizeText(metadata.problem_from),
relatedIssueDomain: normalizeText(metadata.related_issue_domain), relatedIssueDomain: normalizeText(metadata.related_issue_domain),
...@@ -196,46 +618,93 @@ export function normalizeProblemRectificationRecord(record) { ...@@ -196,46 +618,93 @@ export function normalizeProblemRectificationRecord(record) {
actualRectificationCompletedAt: formatDateText(metadata.actual_rectification_completed_at), actualRectificationCompletedAt: formatDateText(metadata.actual_rectification_completed_at),
inspectionCompletedAt: formatDateText(metadata.inspection_completed_at), inspectionCompletedAt: formatDateText(metadata.inspection_completed_at),
dueDate: dueDate && dueDate.isValid() ? dueDate : null, dueDate: dueDate && dueDate.isValid() ? dueDate : null,
fillUsers: toArray(metadata.fill_in_the_people) fillUsers: (
hasDepartmentItems
? departmentItems.map((item) => item.editor)
: toArray(metadata.fill_in_the_people)
)
.map(normalizeUserLabel) .map(normalizeUserLabel)
.filter(Boolean), .filter(Boolean),
reviewUsers: toArray(metadata.review_people) reviewUsers: (
hasDepartmentItems
? departmentItems.map((item) => item.auditor)
: toArray(metadata.review_people)
)
.map(normalizeUserLabel) .map(normalizeUserLabel)
.filter(Boolean), .filter(Boolean),
dueDateLabel: formatDateText(metadata.the_time_of_rectification) || '-', dueDateLabel: formatDateText(summarizedDueDate) || (hasDepartmentItems ? '见部门明细' : '-'),
departmentItems,
hasDepartmentItems,
isFixed, isFixed,
isOverdue, isOverdue,
raw: record, raw: record,
}; };
} }
export async function loadProblemRectificationListData() { export async function loadProblemRectificationListData({
page = 0,
pageSize = DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
filters = {},
gridView = null,
subject = null,
} = {}) {
const requestPage = Math.max(0, Number(page) || 0);
const requestPageSize = PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS.includes(Number(pageSize))
? Number(pageSize)
: DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE;
const result = await queryRecords({ const result = await queryRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT, subject: PROBLEM_RECTIFICATION_SUBJECT,
page: 0, page: requestPage,
page_size: 1000, page_size: requestPageSize,
sort: '-updated_at -created_at', filter: buildProblemRectificationQueryFilter(filters, gridView, subject),
sort: buildProblemRectificationSort(gridView),
mode: 'full', mode: 'full',
}); });
const records = (result?.records || []).map(normalizeProblemRectificationRecord); const records = (result?.records || []).map(normalizeProblemRectificationRecord);
return records; const totalRecords = Number(result?.total_records ?? result?.total ?? records.length);
const totalPages = Number(
result?.total_pages ?? Math.max(1, Math.ceil(totalRecords / requestPageSize)),
);
return {
records,
pagination: {
page: Number(result?.page ?? requestPage),
pageSize: Number(result?.page_size ?? requestPageSize),
totalRecords,
totalPages: Math.max(1, totalPages),
},
};
} }
export async function loadProblemRectificationListResources() { export async function loadProblemRectificationListResources() {
const [records, subject, views] = await Promise.all([ const [subject, views] = await Promise.all([
loadProblemRectificationListData(),
loadSubject(PROBLEM_RECTIFICATION_SUBJECT), loadSubject(PROBLEM_RECTIFICATION_SUBJECT),
getSubjectView(PROBLEM_RECTIFICATION_SUBJECT), getSubjectView(PROBLEM_RECTIFICATION_SUBJECT),
]); ]);
return { return {
records,
subject, subject,
views: views || [], views: views || [],
}; };
} }
export async function loadProblemRectificationViewCountMap({ filters = {}, views = [], subject = null } = {}) {
const entries = await Promise.all(
views.map(async (view) => {
const result = await countRecords({
subject: PROBLEM_RECTIFICATION_SUBJECT,
filter: buildProblemRectificationQueryFilter(filters, view, subject),
});
return [view.name, Number(result?.count ?? result?.total ?? 0)];
}),
);
return Object.fromEntries(entries);
}
export function buildGridViews(views = []) { export function buildGridViews(views = []) {
const gridViews = views const gridViews = views
.filter((view) => view.type === 'grid') .filter((view) => view.type === 'grid')
...@@ -383,13 +852,20 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -383,13 +852,20 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
const sortedRecords = sortRecordsByView(filteredRecords, gridView); const sortedRecords = sortRecordsByView(filteredRecords, gridView);
const progressOptions = Array.from( const subjectOptionValues = (fieldName) =>
new Set(records.map((item) => item.progress).filter(Boolean)), getSelectOptions(findSubjectField(subject, fieldName))
); .map((option) => option.value)
.filter(Boolean);
const auditStatusOptions = Array.from( const progressOptions =
new Set(records.map((item) => item.auditStatus).filter(Boolean)), subjectOptionValues('rectification_progress').length > 0
); ? subjectOptionValues('rectification_progress')
: Array.from(new Set(records.map((item) => item.progress).filter(Boolean)));
const auditStatusOptions =
subjectOptionValues('audit_status').length > 0
? subjectOptionValues('audit_status')
: Array.from(new Set(records.map((item) => item.auditStatus).filter(Boolean)));
const severityOptions = sortLevelOptions( const severityOptions = sortLevelOptions(
Array.from(new Set(records.map((item) => item.severityLevel).filter(Boolean))), Array.from(new Set(records.map((item) => item.severityLevel).filter(Boolean))),
...@@ -403,9 +879,10 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -403,9 +879,10 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
new Set(records.map((item) => item.primaryResponsibleDepartment).filter(Boolean)), new Set(records.map((item) => item.primaryResponsibleDepartment).filter(Boolean)),
).sort((left, right) => left.localeCompare(right, 'zh-CN')); ).sort((left, right) => left.localeCompare(right, 'zh-CN'));
const problemFromOptions = Array.from( const problemFromOptions =
new Set(records.map((item) => item.problemFrom).filter(Boolean)), subjectOptionValues('problem_from').length > 0
); ? subjectOptionValues('problem_from')
: Array.from(new Set(records.map((item) => item.problemFrom).filter(Boolean)));
const rootCauseOptions = Array.from( const rootCauseOptions = Array.from(
new Set(records.map((item) => item.rootCause).filter(Boolean)), new Set(records.map((item) => item.rootCause).filter(Boolean)),
......
...@@ -103,64 +103,103 @@ type Record = { ...@@ -103,64 +103,103 @@ type Record = {
一条记录表示一条待整改问题主记录,是本模块最核心的数据对象。 一条记录表示一条待整改问题主记录,是本模块最核心的数据对象。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | 当前字段定义来自线上 Subject 配置,共 43 个字段。字段值写入时均放入 `metadata`。
| --- | --- | --- | --- | --- | --- |
| 检查项目名称 | `project_name` | text | 否 | 否 | 否 | | 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 可搜索 | 可聚合 |
| 检查实施单位 | `inspect_the_implementing_unit` | select | 否 | 否 | 否 | | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 检查实施时间 | `check_the_implementation_time` | text | 否 | 否 | 否 | | 检查项目名称 | `project_name` | text | `null` | 否 | 否 | 否 | 是 | 否 |
| 检查报告出具日 | `inspection_report_date` | text | 否 | 否 | 否 | | 检查实施单位 | `inspect_the_implementing_unit` | select | `""` | 否 | 否 | 否 | 是 | 否 |
| 问题简述 | `problem_brief` | long_text | 否 | 否 | 否 | | 检查实施时间 | `check_the_implementation_time` | text | `null` | 否 | 否 | 否 | 是 | 否 |
| 问题发现情况 | `problem_discovery_situation` | long_text | 否 | 否 | 否 | | 检查报告出具日 | `inspection_report_date` | text | `null` | 否 | 否 | 否 | 是 | 否 |
| 责任部门 | `responsible_department` | select | 否 | 是 | 否 | | 问题简述 | `problem_brief` | long_text | `""` | 否 | 否 | 否 | 是 | 否 |
| 整改进度 | `rectification_progress` | select | 否 | 否 | 否 | | 责任部门 | `responsible_department` | select | `""` | 否 | 是 | 否 | 是 | 否 |
| 整改情况 | `rectification_situation` | long_text | 否 | 否 | 否 | | 整改进度 | `rectification_progress` | select | `""` | 否 | 否 | 否 | 是 | 否 |
| 问题来源 | `problem_from` | select | 否 | 否 | 否 | | 整改情况 | `rectification_situation` | long_text | `""` | 否 | 否 | 否 | 是 | 否 |
| 问题类型 | `problem_type` | select | 否 | 否 | 否 | | 问题来源 | `problem_from` | select | `""` | 否 | 否 | 否 | 是 | 否 |
| 反馈单位 | `feedback_unit` | select | 否 | 否 | 否 | | 问题类型 | `problem_type` | select | `""` | 否 | 否 | 否 | 是 | 否 |
| 问题涉及业务领域 | `related_issue_domain` | text | 否 | 否 | 否 | | 反馈单位 | `feedback_unit` | select | `鑫元基金风险管理部` | 否 | 否 | 否 | 是 | 否 |
| 预计整改完成时间 | `the_time_of_rectification` | date | 否 | 否 | 否 | | 问题涉及业务领域 | `related_issue_domain` | text | `""` | 否 | 否 | 否 | 是 | 是 |
| 填写人 | `fill_in_the_people` | user | 否 | 是 | 否 | | 预计整改完成时间 | `the_time_of_rectification` | date | `null` | 否 | 否 | 否 | 是 | 否 |
| 复核人 | `review_people` | user | 否 | 是 | 否 | | 填写人 | `fill_in_the_people` | user | `""` | 否 | 是 | 否 | 是 | 否 |
| 审批状态 | `audit_status` | select | 否 | 否 | 否 | | 复核人 | `review_people` | user | `""` | 否 | 是 | 否 | 是 | 否 |
| 最终审批状态 | `final_audit_status` | select | 否 | 否 | 否 | | 审批状态 | `audit_status` | select | `未审批` | 否 | 否 | 否 | 是 | 否 |
| 备注 | `remark` | long_text | 否 | 否 | 否 | | 最终审批状态 | `final_audit_status` | select | `未审批` | 否 | 否 | 否 | 是 | 否 |
| 整改证明材料 | `rectification_proof_materials` | file | 否 | 否 | 否 | | 备注 | `remark` | long_text | `null` | 否 | 否 | 否 | 是 | 否 |
| 是否造成经济损失 | `has_economic_loss` | text | 否 | 否 | 否 | | 整改证明材料 | `rectification_proof_materials` | file | `null` | 否 | 否 | 否 | 是 | 否 |
| 对应经济损失 | `economic_loss_amount` | text | 否 | 否 | 否 | | 是否造成经济损失 | `has_economic_loss` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 是否造成外部影响 | `has_external_impact` | text | 否 | 否 | 否 | | 对应经济损失 | `economic_loss_amount` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 处罚机构 | `penalty_organization` | text | 否 | 否 | 否 | | 是否造成外部影响 | `has_external_impact` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 是否涉及员工问责 | `has_employee_accountability` | text | 否 | 否 | 否 | | 处罚机构 | `penalty_organization` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问责人数 | `accountability_person_count` | text | 否 | 否 | 否 | | 是否涉及员工问责 | `has_employee_accountability` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问题综合严重程度 | `problem_severity_level` | text | 否 | 否 | 否 | | 问责人数 | `accountability_person_count` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问题一级分类 | `problem_category_level_1` | text | 否 | 否 | 否 | | 问题综合严重程度 | `problem_severity_level` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问题二级分类 | `problem_category_level_2` | text | 否 | 否 | 否 | | 问题一级分类 | `problem_category_level_1` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问题三级分类 | `problem_category_level_3` | text | 否 | 否 | 否 | | 问题二级分类 | `problem_category_level_2` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问题四级分类 | `problem_category_level_4` | text | 否 | 否 | 否 | | 问题三级分类 | `problem_category_level_3` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问题原因 | `problem_root_cause` | text | 否 | 否 | 否 | | 问题四级分类 | `problem_category_level_4` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 整改时间评估 | `rectification_timeliness_level` | text | 否 | 否 | 否 | | 问题原因 | `problem_root_cause` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 整改证明材料质量 | `rectification_material_quality_level` | text | 否 | 否 | 否 | | 整改时间评估 | `rectification_timeliness_level` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 是否涉及制度修订 | `involves_policy_revision` | text | 否 | 否 | 否 | | 整改证明材料质量 | `rectification_material_quality_level` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 制度名称 | `policy_revision_name` | text | 否 | 否 | 否 | | 是否涉及制度修订 | `involves_policy_revision` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 是否涉及系统建设 | `involves_system_construction` | text | 否 | 否 | 否 | | 制度名称 | `policy_revision_name` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 系统名称 | `system_construction_name` | text | 否 | 否 | 否 | | 是否涉及系统建设 | `involves_system_construction` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 是否为往期发现问题 | `is_historical_problem` | text | 否 | 否 | 否 | | 系统名称 | `system_construction_name` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 问题是否重复出现 | `is_repeated_problem` | text | 否 | 否 | 否 | | 是否为往期发现问题 | `is_historical_problem` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 综合整改评定等级 | `rectification_overall_rating` | text | 否 | 否 | 否 | | 问题是否重复出现 | `is_repeated_problem` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 实际整改完成时间 | `actual_rectification_completed_at` | text | 否 | 否 | 否 | | 综合整改评定等级 | `rectification_overall_rating` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 实际检查完成日期 | `inspection_completed_at` | text | 否 | 否 | 否 | | 实际整改完成时间 | `actual_rectification_completed_at` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 主责部门 | `primary_responsible_department` | text | 否 | 否 | 否 | | 实际检查完成日期 | `inspection_completed_at` | text | `null` | 否 | 否 | 否 | 否 | 否 |
| 主责部门 | `primary_responsible_department` | text | `null` | 否 | 否 | 否 | 否 | 否 |
### 3.1.1 字段说明 | 部门整改明细 | `department_rectification_items` | object | `[]` | 否 | 是 | 否 | 否 | 否 |
### 3.1.1 select 字段选项
- `inspect_the_implementing_unit`(检查实施单位):`法律合规部`、`源泰律师事务所(合规有效性评估)`、`普华永道会计师事务所`、`风险管理部`、`内部审计`、`内部合规检查`、`外部审计`、`外部检查`、`总行审计`、`工会经费审査`、`国家审计署`、`鑫沅资产`、`鑫元基金`、`其他`
- `responsible_department`(责任部门):`固定收益部`、`权益投资部`、`多元资产投资部`、`量化投资部`、`市场研究部`、`交易部`、`机构销售部`、`渠道销售部`、`电商销售部`、`战略与品宣部`、`客户服务部`、`产品研发部`、`法律合规部`、`审计部(纪委办公室)`、`风险管理部`、`信息技术部`、`基金运营部`、`财务会计部`、`人力资源部(党委组织部)`、`办公室(党委办公室)`、`北京分公司`、`广州分公司`、`(子)投资部`、`(子)研究部`、`(子)交易室`、`(子)市场营销部`、`(子)风控合规部`、`(子)运营部`、`(子)办公室`
- `rectification_progress`(整改进度):`已整改`(颜色 `#a9ef95`)、`整改中`(颜色 `#ea6e60`)、`部分整改`(颜色 `#efa659`)、`不适用`(颜色 `#b2b0a9`)、`已优化`
- `problem_from`(问题来源):`内部检查`、`总行检查`、`监管检查`、`其他外部机构检查`、`内部转办`、`总行转办`、`监管转办`、`其他外部机构转办`
- `problem_type`(问题类型):`业务问题类`、`线索类`
- `feedback_unit`(反馈单位):`鑫元基金风险管理部`(颜色 `#f55353`)、`鑫元基金法律合规部`(颜色 `#63ac52`)、`鑫元基金审计部`(颜色 `#9b2727`)、`鑫沅资产风控合规部`(颜色 `#948686`)
- `audit_status`(审批状态):`未审批`、`已审批`、`已驳回`
- `final_audit_status`(最终审批状态):`未审批`、`已审批`、`已驳回`
### 3.1.2 字段说明
- `responsible_department` 为多值选择字段,旧应用导出/归档时会转成文本或逗号串 - `responsible_department` 为多值选择字段,旧应用导出/归档时会转成文本或逗号串
- `fill_in_the_people`、`review_people` 为多值用户字段,写入时仅保存用户 `_id[]` - `fill_in_the_people`、`review_people` 为多值用户字段,写入时仅保存用户 `_id[]`
- `audit_status` 用于复核阶段结果,旧应用已使用 `已审批`、`已驳回` - `audit_status` 用于复核阶段结果,旧应用已使用 `已审批`、`已驳回`
- `final_audit_status` 用于最终审批阶段结果,旧应用已使用 `已审批`、`已驳回` - `final_audit_status` 用于最终审批阶段结果,旧应用已使用 `已审批`、`已驳回`
- `rectification_progress` 在旧驾驶舱统计中已使用 `已整改`、`已优化` 两个值判断“已整改问题” - `rectification_progress` 在旧驾驶舱统计中已使用 `已整改`、`已优化` 两个值判断“已整改问题”
- `related_issue_domain` 当前配置开启聚合,可用于业务领域统计
- 新增驾驶舱扩展字段当前在线上 Subject 中大多仍为 `text` 类型,前端工作台按标准值引导录入,用于支持后续严重程度、问题分类、问题原因和整改质效分析 - 新增驾驶舱扩展字段当前在线上 Subject 中大多仍为 `text` 类型,前端工作台按标准值引导录入,用于支持后续严重程度、问题分类、问题原因和整改质效分析
- 其余 `select` 类型的可选值必须以线上 Subject 配置中的 `settings.options` 为准,禁止在页面内硬编码 - 其余 `select` 类型的可选值必须以线上 Subject 配置中的 `settings.options` 为准,禁止在页面内硬编码
- `problem_discovery_situation` 曾在历史文档和归档对象中出现,但本次主对象字段定义未包含;新页面只应在 Subject View 或线上字段配置存在时展示,不能作为 `problem_rectification` 固定字段依赖
- `department_rectification_items` 用于新转发流程的部门级整改明细。单条问题即使只有 1 个责任部门,也生成 1 条明细;转发后 `rectification_progress`、`rectification_situation`、`the_time_of_rectification`、`rectification_proof_materials`、`audit_status`、`final_audit_status` 以明细为事实来源,父字段仅做兼容汇总展示
- 部门明细的填写人只能编辑自己负责明细的整改业务字段,送审后(`editor_status = "已完成"`)不再允许编辑;审批人审批通过后(`audit_status = "已审批"`)不再允许编辑,审批驳回后(`audit_status = "已驳回"`)审批人可以编辑该明细的整改业务字段,填写人也恢复编辑。审批人恢复的只是业务字段编辑权限,不获得填写人的送审权限。流程推送人可以编辑本次推送关联的父问题记录及全部部门明细整改业务字段。审批状态、待办 ID、明细 ID 等流程控制字段不作为普通表单字段开放
- 推送人修改父问题记录或部门明细时,不自动重置该明细已经形成的审批状态,也不自动重新生成流程待办
### 3.1.2 metadata 示例 ### 3.1.3 `department_rectification_items` 子结构
| 字段标签 | 字段别名 | 字段类型 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- |
| 明细 ID | `item_id` | text | 是 | 部门明细唯一标识,用于行级保存、审批和待办回写 |
| 责任部门 | `responsible_department` | text | 是 | 当前明细对应的责任部门 |
| 填写人 | `editor` | user | 是 | 保存用户 `_id`,每条明细 1 个填写人 |
| 审批人 | `auditor` | user | 是 | 保存用户 `_id`,每条明细 1 个审批人 |
| 整改进度 | `rectification_progress` | select/text | 否 | 填写人维护,优先使用主对象整改进度选项 |
| 整改情况 | `rectification_situation` | long_text | 否 | 填写人维护 |
| 预计整改完成时间 | `the_time_of_rectification` | date | 否 | 填写人维护,提交时保存 ISO 8601 |
| 整改证明材料 | `rectification_proof_materials` | file | 否 | 填写人上传,按部门明细独立保存 |
| 填写状态 | `editor_status` | text | 否 | `未完成`、`已完成` |
| 审批状态 | `audit_status` | text | 否 | `未审批`、`已审批`、`已驳回` |
| 最终审批状态 | `final_audit_status` | text | 否 | `未审批`、`已审批`、`已驳回` |
| 填写待办 ID | `progress_id_editor` | text | 否 | 当前明细填写待办 ID |
| 审批待办 ID | `progress_id_auditor` | text | 否 | 当前明细审批待办 ID |
| 最终审批待办 ID | `progress_id_sender` | text | 否 | 当前明细最终审批待办 ID |
| 更新人 | `updated_by` | user | 否 | 最近更新用户 `_id` |
| 更新时间 | `updated_at` | date | 否 | 最近更新时间 |
### 3.1.4 metadata 示例
```json ```json
{ {
...@@ -169,7 +208,6 @@ type Record = { ...@@ -169,7 +208,6 @@ type Record = {
"check_the_implementation_time": "2026年第一季度", "check_the_implementation_time": "2026年第一季度",
"inspection_report_date": "2026-04-30", "inspection_report_date": "2026-04-30",
"problem_brief": "部分整改项未按期闭环。", "problem_brief": "部分整改项未按期闭环。",
"problem_discovery_situation": "检查发现两项制度执行存在时效性缺口。",
"responsible_department": [ "responsible_department": [
"<责任部门选项值 A>", "<责任部门选项值 A>",
"<责任部门选项值 B>" "<责任部门选项值 B>"
...@@ -256,11 +294,12 @@ type Record = { ...@@ -256,11 +294,12 @@ type Record = {
} }
``` ```
## 3.4 问题整改流程消息主单 ## 3.4 信息关联id(问题整改流程消息主单)
- **subject**: `message_reference_id` - **subject**: `message_reference_id`
- **对象名称**: 信息关联id
这是旧应用中用于承载“推送填写 / 复核 / 最终审批”流程的辅助对象,不是业务展示主表,但新项目若需要兼容既有流程链接,应保留该协议说明。 这是旧应用中用于承载“推送填写 / 复核 / 最终审批”流程的辅助对象,线上对象名称为“信息关联id”。它不是业务展示主表,但新项目若需要兼容既有流程链接,应保留该协议说明。
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | | 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
...@@ -272,6 +311,8 @@ type Record = { ...@@ -272,6 +311,8 @@ type Record = {
| 对象名 | `subject_name` | text | 否 | 否 | 否 | | 对象名 | `subject_name` | text | 否 | 否 | 否 |
| 视图名称 | `view_name` | text | 否 | 否 | 否 | | 视图名称 | `view_name` | text | 否 | 否 | 否 |
| 关联记录 ID | `reference_record_id` | text | 否 | 是 | 否 | | 关联记录 ID | `reference_record_id` | text | 否 | 是 | 否 |
| 流程模式 | `flow_mode` | text | 否 | 否 | 否 |
| 部门明细 ID | `department_item_ids` | text | 否 | 是 | 否 |
| 填写待办 ID | `progress_id_editor` | text | 否 | 是 | 否 | | 填写待办 ID | `progress_id_editor` | text | 否 | 是 | 否 |
| 审核待办 ID | `progress_id_auditor` | text | 否 | 是 | 否 | | 审核待办 ID | `progress_id_auditor` | text | 否 | 是 | 否 |
| 最终审核待办 ID | `progress_id_sender` | text | 否 | 否 | 否 | | 最终审核待办 ID | `progress_id_sender` | text | 否 | 否 | 否 |
...@@ -285,6 +326,8 @@ type Record = { ...@@ -285,6 +326,8 @@ type Record = {
- `restriction_value` 保存被推送整改记录的 `_id[]` - `restriction_value` 保存被推送整改记录的 `_id[]`
- `reference_record_id` 与 `restriction_value` 保持一致,作为显式的“关联记录 ID”冗余字段 - `reference_record_id` 与 `restriction_value` 保持一致,作为显式的“关联记录 ID”冗余字段
- `subject_name` 保存被推送记录所属对象名,当前整改流程固定为 `problem_rectification` - `subject_name` 保存被推送记录所属对象名,当前整改流程固定为 `problem_rectification`
- `flow_mode` 区分新旧流程:`department` 表示单条问题按责任部门生成明细行,`batch` 表示多选问题的单填写人 / 单审批人简单推送;历史数据可能为空
- `department_item_ids` 保存本次部门流程涉及的 `department_rectification_items[].item_id`
- `progress_id_editor`、`progress_id_auditor`、`progress_id_sender` 用于记录每一步用户确认动作生成的流程 ID,用来区分当前流程推进到了哪一步 - `progress_id_editor`、`progress_id_auditor`、`progress_id_sender` 用于记录每一步用户确认动作生成的流程 ID,用来区分当前流程推进到了哪一步
- `editor_status`:当前项目统一使用 `未完成`、`已完成` - `editor_status`:当前项目统一使用 `未完成`、`已完成`
- `auditor_status`:当前项目统一使用 `未完成`、`已完成` - `auditor_status`:当前项目统一使用 `未完成`、`已完成`
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment