Commit 8bb072d7 authored by David Yang's avatar David Yang

feat: improve problem rectification workflow ui

parent 78c9be51
......@@ -376,22 +376,6 @@ export default function ProblemRectificationLayout() {
<div className="text-sm text-zinc-500">{currentGroupTitle}</div>
</div>
<div className="mb-4 rounded-3xl border border-white/80 bg-white/75 px-5 py-4 shadow-sm backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-rose-500">
Problem Rectification
</div>
<div className="mt-1 text-xl font-semibold text-slate-900">
问题整改模块重构
</div>
</div>
<div className="text-sm text-slate-500">
先保证业务流转通顺,再逐步补齐细节体验。
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto rounded-[28px] border border-white/80 bg-white/80 p-4 shadow-sm backdrop-blur sm:p-6">
<Outlet />
</div>
......
import ModuleEmptyState from '../ModuleEmptyState';
import { Button } from '@/catalyst-ui-kit/javascript/button';
import {
Dialog,
DialogActions,
DialogBody,
DialogDescription,
DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import TailwindEditableField from '@/pages/RecordDetailDrawer/components/TailwindEditableField';
import { updateRecord } from '@/services/DataService';
import { normalizeText } from '@/utils/subjectView';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
import { UserContext } from '@/wrapper/Auth';
import {
approveByAuditor,
approveBySender,
loadProblemRectificationFlow,
markEditorComplete,
refreshRelatedProblemRecords,
rejectByAuditor,
rejectBySender,
} from '../problemRectificationFormService';
const EDITABLE_FIELD_NAMES = [
'rectification_progress',
'rectification_situation',
'the_time_of_rectification',
'remark',
'rectification_proof_materials',
];
function formatUserName(user) {
if (!user) {
return '-';
}
return user.display_name || user.username || user.name || user._id || '-';
}
function formatUserList(users = []) {
if (!users.length) {
return '-';
}
return users.map((item) => formatUserName(item)).join('、');
}
function formatDateText(value) {
if (!value) {
return '-';
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : normalizeText(value) || '-';
}
function StepBadge({ title, status, active }) {
return (
<div
className={clsx(
'rounded-[24px] border px-4 py-4 shadow-sm transition',
active
? 'border-amber-300 bg-amber-50/90'
: status === '已完成'
? 'border-emerald-200 bg-emerald-50/90'
: 'border-white/80 bg-white/80',
)}
>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
{title}
</div>
<div className="mt-2 text-lg font-semibold text-slate-900">{status || '未完成'}</div>
</div>
);
}
function SummaryCard({ label, value, hint }) {
return (
<div className="rounded-[24px] border border-white/80 bg-white/82 px-5 py-4 shadow-sm">
<div className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">
{label}
</div>
<div className="mt-2 text-base font-semibold text-slate-900">{value}</div>
{hint ? <div className="mt-1 text-sm text-slate-500">{hint}</div> : null}
</div>
);
}
function StatusTag({ value }) {
const toneClass =
value === '已完成' || value === '已审批'
? 'bg-emerald-50 text-emerald-700 ring-emerald-100'
: value === '已驳回'
? '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-3 py-1 text-xs font-medium ring-1', toneClass)}>
{value || '未完成'}
</span>
);
}
function EmptyState({ title, description }) {
return (
<div className="rounded-[28px] border border-dashed border-zinc-300 bg-white/70 px-6 py-14 text-center">
<div className="text-xl font-semibold text-slate-900">{title}</div>
<div className="mt-3 text-sm leading-6 text-slate-500">{description}</div>
</div>
);
}
function FieldBlock({ label, fieldDef, value, readonly, disabled, onSave }) {
return (
<div className="rounded-[22px] border border-zinc-200 bg-zinc-50/70 p-4">
<div className="mb-3 text-sm font-medium text-slate-700">{label}</div>
<TailwindEditableField
value={value}
fieldDef={fieldDef}
readonly={readonly}
disabled={disabled}
onSave={onSave}
/>
</div>
);
}
export default function ProblemRectificationFormPage() {
const userInfo = useContext(UserContext);
const [searchParams] = useSearchParams();
const messageId = searchParams.get('id') || '';
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [flowData, setFlowData] = useState(null);
const [selectedRecord, setSelectedRecord] = useState(null);
const reload = async () => {
if (!messageId) {
setFlowData(null);
setLoading(false);
setError('缺少流程 ID,无法加载整改流程。');
return;
}
setLoading(true);
setError('');
try {
const result = await loadProblemRectificationFlow(messageId, userInfo);
setFlowData(result);
} catch (requestError) {
setFlowData(null);
setError(requestError?.message || '整改流程加载失败,请稍后重试。');
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, [messageId, userInfo?._id]);
const messageRecord = flowData?.messageRecord || null;
const subject = flowData?.subject || null;
const participants = flowData?.participants || {};
const relatedRecords = flowData?.relatedRecords || [];
const currentStep = flowData?.currentStep || 'editor';
const viewerRole = flowData?.viewerRole || 'guest';
const canEditRecords = Boolean(flowData?.canEditRecords);
const metadata = messageRecord?.metadata || {};
const fieldMap = useMemo(() => {
const entries = (subject?.fields || []).map((field) => [field.name, field]);
return Object.fromEntries(entries);
}, [subject]);
const editableFields = useMemo(
() =>
EDITABLE_FIELD_NAMES.map((name) => fieldMap[name]).filter(Boolean),
[fieldMap],
);
const summary = useMemo(() => {
const completedRectificationCount = relatedRecords.filter(
(record) =>
record.progress === '已整改' ||
record.progress === '已优化',
).length;
const approvedCount = relatedRecords.filter(
(record) => normalizeText(record.auditStatus) === '已审批',
).length;
return {
total: relatedRecords.length,
completedRectificationCount,
approvedCount,
};
}, [relatedRecords]);
const refreshAfterMutation = async () => {
const nextMessage = await loadProblemRectificationFlow(messageId, userInfo);
setFlowData(nextMessage);
if (selectedRecord) {
const refreshedRecords = await refreshRelatedProblemRecords(nextMessage.messageRecord);
const matched = refreshedRecords.find((item) => item.id === selectedRecord.id);
setSelectedRecord(matched || null);
}
};
const handleSaveField = async (fieldName, nextValue) => {
if (!selectedRecord) {
return;
}
await updateRecord(selectedRecord.id, {
metadata: {
[fieldName]:
fieldMap[fieldName]?.type === 'date' && nextValue
? dayjs(nextValue).isValid()
? dayjs(nextValue).toISOString()
: nextValue
: nextValue,
},
});
await refreshAfterMutation();
};
const withSubmit = async (handler) => {
if (submitting) {
return;
}
setSubmitting(true);
setError('');
try {
await handler();
await refreshAfterMutation();
} catch (requestError) {
setError(requestError?.message || '流程处理失败,请稍后重试。');
} finally {
setSubmitting(false);
}
};
const handleEditorSubmit = async () => {
await withSubmit(async () => {
await markEditorComplete({
messageRecord,
currentUser: userInfo,
auditors: participants.auditors || [],
});
});
};
const handleAuditorApprove = async () => {
await withSubmit(async () => {
await approveByAuditor({
messageRecord,
currentUser: userInfo,
sender: participants.sender,
});
});
};
const handleAuditorReject = async () => {
await withSubmit(async () => {
await rejectByAuditor({
messageRecord,
currentUser: userInfo,
editors: participants.editors || [],
});
});
};
const handleSenderApprove = async () => {
await withSubmit(async () => {
await approveBySender({
messageRecord,
});
});
};
const handleSenderReject = async () => {
await withSubmit(async () => {
await rejectBySender({
messageRecord,
currentUser: userInfo,
editors: participants.editors || [],
});
});
};
const actionHint =
viewerRole === 'editor'
? '你当前是填写人,请完善整改内容后点击送审。'
: viewerRole === 'auditor'
? '你当前是复核人,请核对整改内容后进行通过或驳回。'
: viewerRole === 'sender'
? '你当前是发送人,请在复核完成后执行最终审核。'
: '你当前不是本流程参与人,仅可查看。';
return (
<ModuleEmptyState
title="整改填写 / 复核 / 最终审核页"
description="按流程处理整改记录。"
<div className="space-y-6">
<section className="overflow-hidden rounded-[34px] border border-white/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(255,247,237,0.95)_45%,rgba(240,249,255,0.95))] px-6 py-6 shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="flex flex-wrap items-start justify-between gap-5">
<div className="max-w-4xl">
<div className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-600">
Problem Rectification Flow
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
整改填写 / 复核 / 最终审核
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
当前流程通过 <code className="rounded bg-white/80 px-1.5 py-0.5 text-xs">{messageId || '-'}</code>{' '}
绑定整改记录。页面会按照“填写人、审核人、发送人”三段式推进,并把流程状态回写到
<code className="mx-1 rounded bg-white/80 px-1.5 py-0.5 text-xs">message_reference_id.metadata</code>
中。
</p>
</div>
<div className="min-w-64 rounded-[26px] border border-white/80 bg-white/82 p-4 shadow-sm">
<div className="text-sm font-medium text-slate-500">当前身份</div>
<div className="mt-2 text-lg font-semibold text-slate-900">
{viewerRole === 'editor'
? '填写人'
: viewerRole === 'auditor'
? '审核人'
: viewerRole === 'sender'
? '发送人'
: '只读查看'}
</div>
<div className="mt-2 text-sm leading-6 text-slate-500">{actionHint}</div>
</div>
</div>
</section>
{loading ? (
<EmptyState title="正在加载流程" description="系统正在读取 message_reference_id 与关联整改记录。" />
) : error ? (
<div className="rounded-[28px] border border-rose-200 bg-rose-50 px-6 py-5 text-sm text-rose-700 shadow-sm">
{error}
</div>
) : !messageRecord ? (
<EmptyState title="未找到流程数据" description="请确认消息链接中的 id 是否正确,或检查流程主单是否已存在。" />
) : (
<>
<section className="grid gap-4 lg:grid-cols-3">
<StepBadge
title="填写阶段"
status={metadata.editor_status || '未完成'}
active={currentStep === 'editor'}
/>
<StepBadge
title="复核阶段"
status={metadata.auditor_status || '未完成'}
active={currentStep === 'auditor'}
/>
<StepBadge
title="最终审核"
status={metadata.sender_status || '未完成'}
active={currentStep === 'sender'}
/>
</section>
<section className="grid gap-4 lg:grid-cols-4">
<SummaryCard label="关联记录数" value={`${summary.total} 条`} hint={`约束字段:${metadata.restriction_key || '_id'}`} />
<SummaryCard label="已整改记录" value={`${summary.completedRectificationCount} 条`} hint="以整改进度为准" />
<SummaryCard label="复核通过" value={`${summary.approvedCount} 条`} hint="以 audit_status 为准" />
<SummaryCard label="当前对象" value={metadata.subject_name || '-'} hint={`视图:${metadata.view_name || '-'}`} />
</section>
<section className="grid gap-4 xl:grid-cols-[1.2fr,1fr]">
<div className="rounded-[30px] border border-white/80 bg-white/82 p-5 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-lg font-semibold text-slate-900">关联整改记录</div>
<div className="mt-1 text-sm text-slate-500">
点击“查看/填写”可进入单条整改详情维护。
</div>
</div>
</div>
{relatedRecords.length === 0 ? (
<div className="mt-4 rounded-[24px] border border-dashed border-zinc-300 bg-zinc-50/70 px-6 py-12 text-center text-sm text-slate-500">
当前流程还没有绑定整改记录。
</div>
) : (
<div className="mt-4">
<Table bleed className="[--gutter:--spacing(5)]">
<TableHead>
<TableRow>
<TableHeader>标题</TableHeader>
<TableHeader>反馈单位</TableHeader>
<TableHeader>整改进度</TableHeader>
<TableHeader>复核状态</TableHeader>
<TableHeader>最终审批</TableHeader>
<TableHeader>预计完成时间</TableHeader>
<TableHeader>操作</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{relatedRecords.map((record) => (
<TableRow key={record.id}>
<TableCell>
<div className="font-medium text-slate-900">{record.title || '-'}</div>
<div className="mt-1 text-xs text-slate-500">{record.projectName || '-'}</div>
</TableCell>
<TableCell>{record.feedbackUnit || '-'}</TableCell>
<TableCell>
<StatusTag value={record.progress || '未填写'} />
</TableCell>
<TableCell>
<StatusTag value={record.auditStatus || '未完成'} />
</TableCell>
<TableCell>
<StatusTag value={record.finalAuditStatus || '未完成'} />
</TableCell>
<TableCell>{record.dueDateLabel || '-'}</TableCell>
<TableCell>
<Button
outline
onClick={() => setSelectedRecord(record)}
>
{canEditRecords ? '查看/填写' : '查看详情'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<div className="space-y-4">
<div className="rounded-[30px] border border-white/80 bg-white/82 p-5 shadow-sm">
<div className="text-lg font-semibold text-slate-900">流程参与人</div>
<div className="mt-4 grid gap-4">
<SummaryCard label="发送人" value={formatUserName(participants.sender)} />
<SummaryCard label="填写人" value={formatUserList(participants.editors || [])} />
<SummaryCard label="审核人" value={formatUserList(participants.auditors || [])} />
</div>
</div>
<div className="rounded-[30px] border border-white/80 bg-white/82 p-5 shadow-sm">
<div className="text-lg font-semibold text-slate-900">流程操作</div>
<div className="mt-2 text-sm leading-6 text-slate-500">
页面根据当前步骤和你的身份自动开放可执行动作。
</div>
<div className="mt-5 flex flex-wrap gap-3">
{currentStep === 'editor' && viewerRole === 'editor' ? (
<Button color="blue" disabled={submitting || relatedRecords.length === 0} onClick={handleEditorSubmit}>
{submitting ? '送审中...' : '填写完成并送审'}
</Button>
) : null}
{currentStep === 'auditor' && viewerRole === 'auditor' ? (
<>
<Button color="emerald" disabled={submitting || relatedRecords.length === 0} onClick={handleAuditorApprove}>
{submitting ? '处理中...' : '复核通过'}
</Button>
<Button color="red" disabled={submitting || relatedRecords.length === 0} onClick={handleAuditorReject}>
{submitting ? '处理中...' : '驳回复填'}
</Button>
</>
) : null}
{currentStep === 'sender' && viewerRole === 'sender' ? (
<>
<Button color="blue" disabled={submitting || relatedRecords.length === 0} onClick={handleSenderApprove}>
{submitting ? '处理中...' : '最终审核通过'}
</Button>
<Button color="red" disabled={submitting || relatedRecords.length === 0} onClick={handleSenderReject}>
{submitting ? '处理中...' : '最终审核驳回'}
</Button>
</>
) : null}
<Button outline disabled={submitting} onClick={reload}>
刷新流程
</Button>
</div>
</div>
</div>
</section>
</>
)}
<Dialog open={Boolean(selectedRecord)} onClose={() => setSelectedRecord(null)} size="4xl">
<DialogTitle>{selectedRecord?.title || '整改记录详情'}</DialogTitle>
<DialogDescription>
当前记录仅开放整改填写阶段的核心字段编辑。所有写入仍通过
<code className="mx-1 rounded bg-zinc-100 px-1.5 py-0.5 text-xs">metadata</code>
提交。
</DialogDescription>
<DialogBody>
{selectedRecord ? (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<SummaryCard label="问题标题" value={selectedRecord.title || '-'} />
<SummaryCard label="反馈单位" value={selectedRecord.feedbackUnit || '-'} />
<SummaryCard label="整改进度" value={selectedRecord.progress || '-'} />
<SummaryCard label="预计完成时间" value={formatDateText(selectedRecord.raw?.metadata?.the_time_of_rectification)} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{editableFields.map((fieldDef) => (
<FieldBlock
key={fieldDef.name}
label={fieldDef.label || fieldDef.name}
fieldDef={fieldDef}
value={selectedRecord.raw?.metadata?.[fieldDef.name]}
readonly={!canEditRecords}
disabled={!canEditRecords}
onSave={(nextValue) => handleSaveField(fieldDef.name, nextValue)}
/>
))}
</div>
</div>
) : null}
</DialogBody>
<DialogActions>
<Button outline onClick={() => setSelectedRecord(null)}>
关闭
</Button>
</DialogActions>
</Dialog>
</div>
);
}
import { Button } from '@/catalyst-ui-kit/javascript/button';
import {
Dialog,
DialogActions,
DialogBody,
DialogDescription,
DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import {
batchRemoveRecords,
batchUpdateRecords,
createNotice,
createRecord,
executeAction,
loadSubject,
loadUser,
queryUsers,
sendNotice,
updateRecord,
} from '@/services/DataService';
import * as Headless from '@headlessui/react';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
emptyValueForField,
getSelectOptions,
normalizeText,
} from '@/utils/subjectView';
import { DEPARTMENT_GROUPS } from '../problemRectificationDashboardService';
import {
PROBLEM_RECTIFICATION_SUBJECT,
buildProblemRectificationCreatePayload,
buildProblemRectificationListView,
loadProblemRectificationListData,
} from '../problemRectificationListService';
const INITIAL_FORM_VALUES = {
projectName: '',
inspectUnit: '',
implementationTime: '',
inspectionReportDate: '',
brief: '',
detail: '',
responsibleDepartmentsText: '',
progress: '',
rectificationSituation: '',
problemFrom: '',
problemType: '',
feedbackUnit: '',
relatedIssueDomain: '',
dueDate: '',
remark: '',
const CREATE_FIELD_ORDER = [
'project_name',
'inspect_the_implementing_unit',
'check_the_implementation_time',
'inspection_report_date',
'rectification_progress',
'problem_from',
'problem_type',
'feedback_unit',
'related_issue_domain',
'the_time_of_rectification',
'responsible_department',
'problem_brief',
'problem_discovery_situation',
'rectification_situation',
'remark',
];
const FIELD_HINTS = {
inspect_the_implementing_unit: '按对象配置的实施单位选项选择',
rectification_progress: '状态选项来自对象配置',
problem_from: '问题来源选项来自对象配置',
problem_type: '问题类型选项来自对象配置',
feedback_unit: '反馈单位选项来自对象配置',
responsible_department: '可多选责任部门',
the_time_of_rectification: '提交时会自动转成 ISO 8601 时间',
};
function SummaryCard({ label, value, hint, tone = 'default' }) {
......@@ -116,87 +145,235 @@ function formatUsers(users) {
return users && users.length > 0 ? users.join('、') : '-';
}
function ModalCard({ title, description, children, footer }) {
function SelectedUsers({ users, onRemove }) {
if (users.length === 0) {
return (
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/45 p-4 backdrop-blur-sm">
<div className="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-[30px] border border-white/70 bg-white p-6 shadow-[0_30px_80px_rgba(15,23,42,0.22)]">
<div className="border-b border-zinc-100 pb-4">
<div className="text-xl font-semibold text-slate-900">{title}</div>
{description ? (
<div className="mt-2 text-sm leading-6 text-slate-500">
{description}
</div>
) : null}
</div>
<div className="py-5">{children}</div>
{footer ? (
<div className="border-t border-zinc-100 pt-4">{footer}</div>
) : null}
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50/80 px-4 py-3 text-sm text-slate-500">
暂未选择
</div>
);
}
return (
<div className="flex flex-wrap gap-2">
{users.map((user) => (
<span
key={user._id}
className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm ring-1 ring-zinc-200"
>
<span>{user.display_name || user.username}</span>
<button
type="button"
onClick={() => onRemove(user._id)}
className="rounded-full text-slate-400 transition hover:text-rose-600"
aria-label={`移除 ${user.display_name || user.username}`}
>
×
</button>
</span>
))}
</div>
);
}
function UserMultiSelect({
function RemoteUserSelect({
label,
value,
onChange,
options,
onSearch,
placeholder,
onSearch,
}) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
let cancelled = false;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
const loadOptions = async () => {
setLoading(true);
try {
const users = await onSearch(keyword);
if (!cancelled && requestIdRef.current === requestId) {
setOptions(users);
}
} catch (error) {
if (!cancelled && requestIdRef.current === requestId) {
setOptions([]);
}
} finally {
if (!cancelled && requestIdRef.current === requestId) {
setLoading(false);
}
}
};
loadOptions();
return () => {
cancelled = true;
};
}, [keyword, onSearch]);
const selectedUsers = useMemo(() => value.filter(Boolean), [value]);
const selectedIds = useMemo(
() => new Set(selectedUsers.map((user) => user._id)),
[selectedUsers],
);
const availableOptions = options.filter((user) => !selectedIds.has(user._id));
return (
<div>
<div className="space-y-3">
<label className="mb-2 block text-sm font-medium text-slate-700">
{label}
</label>
<input
type="text"
onChange={(event) => onSearch(event.target.value)}
<Headless.Combobox
value={null}
nullable
onChange={(user) => {
if (!user || selectedIds.has(user._id)) {
return;
}
onChange([...selectedUsers, user]);
setKeyword('');
}}
>
<div className="relative">
<Headless.ComboboxInput
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
displayValue={() => keyword}
placeholder={placeholder}
className="mb-3 w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
className="block w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/>
<div className="max-h-52 space-y-2 overflow-y-auto rounded-2xl border border-zinc-200 bg-zinc-50 p-3">
{options.length > 0 ? (
options.map((user) => {
const checked = value.includes(user._id);
return (
<label
<Headless.ComboboxButton className="absolute inset-y-0 right-0 flex items-center px-3 text-slate-400">
<svg className="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M6 8l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Headless.ComboboxButton>
<Headless.ComboboxOptions className="absolute z-30 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-zinc-200 bg-white p-2 shadow-xl ring-1 ring-black/5 empty:hidden">
{loading ? (
<div className="rounded-xl px-3 py-3 text-sm text-slate-500">搜索中...</div>
) : availableOptions.length > 0 ? (
availableOptions.map((user) => (
<Headless.ComboboxOption
key={user._id}
className="flex cursor-pointer items-center gap-3 rounded-xl bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
value={user}
className="cursor-pointer rounded-xl px-3 py-2 text-sm text-slate-700 data-focus:bg-rose-50 data-focus:text-slate-900"
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
onChange([...value, user._id]);
} else {
onChange(value.filter((item) => item !== user._id));
}
}}
/>
<div>
<div className="font-medium text-slate-900">
{user.display_name || user.username}
</div>
<div className="text-xs text-slate-500">{user.username}</div>
</div>
</label>
);
})
</Headless.ComboboxOption>
))
) : (
<div className="rounded-xl bg-white px-3 py-4 text-sm text-slate-500">
暂无可选用户
<div className="rounded-xl px-3 py-3 text-sm text-slate-500">
{keyword.trim() ? '没有匹配的用户' : '请输入关键词搜索用户'}
</div>
)}
</Headless.ComboboxOptions>
</div>
</Headless.Combobox>
<SelectedUsers
users={selectedUsers}
onRemove={(userId) =>
onChange(selectedUsers.filter((user) => user._id !== userId))
}
/>
</div>
);
}
function buildInitialCreateValues(subject) {
return (subject?.fields || [])
.filter((field) => CREATE_FIELD_ORDER.includes(field.name))
.reduce((result, field) => {
result[field.name] = emptyValueForField(field);
return result;
}, {});
}
function normalizeFieldList(subject) {
const fieldMap = new Map((subject?.fields || []).map((field) => [field.name, field]));
return CREATE_FIELD_ORDER.map((fieldName) => fieldMap.get(fieldName)).filter(Boolean);
}
function CreateField({ fieldDef, value, onChange }) {
const options = getSelectOptions(fieldDef);
const isMultiple = Boolean(fieldDef?.multiple);
const fieldType = fieldDef?.type || 'text';
const placeholder = FIELD_HINTS[fieldDef?.name] || `请输入${fieldDef?.label || fieldDef?.name}`;
if (fieldType === 'long_text') {
return (
<Textarea
rows={fieldDef?.name === 'problem_discovery_situation' ? 4 : 3}
value={value || ''}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
);
}
if (fieldType === 'date') {
return (
<Input
type="date"
value={value || ''}
onChange={(event) => onChange(event.target.value)}
/>
);
}
if (fieldType === 'select') {
return (
<Select
multiple={isMultiple}
value={isMultiple ? value || [] : 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}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
);
}
return (
<Input
value={value || ''}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
);
}
export default function ProblemRectificationListPage() {
const [records, setRecords] = useState([]);
const [createSubject, setCreateSubject] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState(false);
......@@ -209,10 +386,9 @@ export default function ProblemRectificationListPage() {
const [selectedIds, setSelectedIds] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showPushModal, setShowPushModal] = useState(false);
const [formValues, setFormValues] = useState(INITIAL_FORM_VALUES);
const [fillerIds, setFillerIds] = useState([]);
const [auditorIds, setAuditorIds] = useState([]);
const [userOptions, setUserOptions] = useState([]);
const [formValues, setFormValues] = useState({});
const [fillerUsers, setFillerUsers] = useState([]);
const [auditorUsers, setAuditorUsers] = useState([]);
const reloadList = async () => {
setLoading(true);
......@@ -237,13 +413,14 @@ export default function ProblemRectificationListPage() {
(async () => {
try {
const result = await queryUsers(0, 20, {});
const subject = await loadSubject(PROBLEM_RECTIFICATION_SUBJECT);
if (!cancelled) {
setUserOptions(result?.users || []);
setCreateSubject(subject);
setFormValues(buildInitialCreateValues(subject));
}
} catch (requestError) {
if (!cancelled) {
setUserOptions([]);
setCreateSubject(null);
}
}
})();
......@@ -257,6 +434,14 @@ export default function ProblemRectificationListPage() {
() => buildProblemRectificationListView(records, filters),
[records, filters],
);
const createFields = useMemo(
() => normalizeFieldList(createSubject),
[createSubject],
);
const initialCreateValues = useMemo(
() => buildInitialCreateValues(createSubject),
[createSubject],
);
const filteredRecords = view.filteredRecords || [];
const groupCounts = view.groupCounts || {};
......@@ -290,7 +475,10 @@ export default function ProblemRectificationListPage() {
};
const handleCreate = async () => {
if (!formValues.projectName.trim() && !formValues.brief.trim()) {
if (
!normalizeText(formValues.project_name) &&
!normalizeText(formValues.problem_brief)
) {
setError('新增记录至少需要填写检查项目名称或问题简述。');
return;
}
......@@ -299,16 +487,10 @@ export default function ProblemRectificationListPage() {
setError('');
try {
const payload = buildProblemRectificationCreatePayload({
...formValues,
responsibleDepartments: formValues.responsibleDepartmentsText
.split(/[,,]/)
.map((item) => item.trim())
.filter(Boolean),
});
const payload = buildProblemRectificationCreatePayload(formValues, createFields);
await createRecord(payload);
setShowCreateModal(false);
setFormValues(INITIAL_FORM_VALUES);
setFormValues(initialCreateValues);
await reloadList();
} catch (requestError) {
setError(requestError?.message || '新增问题整改记录失败,请稍后重试。');
......@@ -317,6 +499,11 @@ export default function ProblemRectificationListPage() {
}
};
const handleCloseCreateModal = () => {
setShowCreateModal(false);
setFormValues(initialCreateValues);
};
const handleDelete = async (targetRecords) => {
if (!targetRecords || targetRecords.length === 0) {
setError('请先选择需要删除的记录。');
......@@ -356,24 +543,24 @@ export default function ProblemRectificationListPage() {
}
};
const handleSearchUsers = async (keyword) => {
try {
const handleSearchUsers = useCallback(async (keyword) => {
const filter = keyword.trim()
? {
$or: [
{ username: { $regex: keyword.trim() } },
{ display_name: { $regex: keyword.trim() } },
{ username: { $regex: keyword.trim(), $options: 'i' } },
{ display_name: { $regex: keyword.trim(), $options: 'i' } },
],
}
: {};
const result = await queryUsers(0, 20, filter);
setUserOptions(result?.users || []);
} catch (requestError) {
setUserOptions([]);
}
};
return result?.users || [];
}, []);
const handlePush = async () => {
const fillerIds = fillerUsers.map((user) => user._id).filter(Boolean);
const auditorIds = auditorUsers.map((user) => user._id).filter(Boolean);
if (selectedRecords.length === 0) {
setError('请先勾选需要推送的问题整改记录。');
return;
......@@ -401,8 +588,12 @@ export default function ProblemRectificationListPage() {
auditor: auditorIds,
restriction_key: '_id',
restriction_value: selectedRecords.map((item) => item.id),
reference_record_id: selectedRecords.map((item) => item.id),
view_name: filters.groupKey,
subject_name: 'problem_rectification',
editor_status: '未完成',
auditor_status: '未完成',
sender_status: '未完成',
},
});
......@@ -451,8 +642,8 @@ export default function ProblemRectificationListPage() {
await sendNotice(noticeResult.name);
setShowPushModal(false);
setFillerIds([]);
setAuditorIds([]);
setFillerUsers([]);
setAuditorUsers([]);
setSelectedIds([]);
await reloadList();
} catch (requestError) {
......@@ -464,29 +655,6 @@ export default function ProblemRectificationListPage() {
return (
<div className="space-y-6">
<section className="rounded-[34px] border border-white/80 bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,242,0.94))] px-6 py-6 shadow-[0_24px_60px_rgba(15,23,42,0.06)]">
<div className="flex flex-wrap items-end justify-between gap-5">
<div className="max-w-3xl">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-rose-500">
Problem Rectification Records
</div>
<div className="mt-2 text-3xl font-semibold tracking-tight text-slate-900">
问题整改主列表
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="rounded-full bg-[linear-gradient(135deg,#7f1d1d,#be123c)] px-5 py-3 text-sm font-medium text-white shadow-lg transition hover:brightness-105"
>
新增记录
</button>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
<SummaryCard
label="当前分组问题数"
......@@ -634,6 +802,13 @@ export default function ProblemRectificationListPage() {
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="rounded-full bg-[linear-gradient(135deg,#7f1d1d,#be123c)] px-4 py-2 text-sm font-medium text-white shadow-lg transition hover:brightness-105"
>
新增记录
</button>
<button
type="button"
disabled={selectedIds.length === 0 || actionLoading}
......@@ -814,215 +989,124 @@ export default function ProblemRectificationListPage() {
)}
</section>
{showCreateModal ? (
<ModalCard
title="新增问题整改记录"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => {
setShowCreateModal(false);
setFormValues(INITIAL_FORM_VALUES);
}}
className="rounded-2xl border border-zinc-200 px-4 py-2 text-sm font-medium text-slate-600"
>
取消
</button>
<button
type="button"
disabled={actionLoading}
onClick={handleCreate}
className="rounded-2xl bg-[linear-gradient(135deg,#7f1d1d,#be123c)] px-5 py-2 text-sm font-medium text-white shadow-lg disabled:opacity-60"
>
{actionLoading ? '提交中...' : '确认新增'}
</button>
</div>
}
>
<div className="grid gap-4 md:grid-cols-2">
{[
['检查项目名称', 'projectName', '请输入检查项目名称'],
['检查实施单位', 'inspectUnit', '请输入检查实施单位'],
['检查实施时间', 'implementationTime', '例如 2026年第一季度'],
['检查报告出具日', 'inspectionReportDate', '例如 2026-04-30'],
['整改进度', 'progress', '例如 整改中 / 已整改'],
['问题来源', 'problemFrom', '请输入问题来源'],
['问题类型', 'problemType', '请输入问题类型'],
['反馈单位', 'feedbackUnit', '请输入反馈单位'],
['问题涉及业务领域', 'relatedIssueDomain', '请输入问题涉及业务领域'],
['预计整改完成时间', 'dueDate', '例如 2026-12-31T00:00:00.000Z'],
].map(([label, field, placeholder]) => (
<div key={field}>
<label className="mb-2 block text-sm font-medium text-slate-700">
{label}
</label>
<input
value={formValues[field]}
onChange={(event) =>
setFormValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={placeholder}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/>
</div>
))}
<div className="md:col-span-2">
<label className="mb-2 block text-sm font-medium text-slate-700">
责任部门
</label>
<input
value={formValues.responsibleDepartmentsText}
onChange={(event) =>
setFormValues((current) => ({
...current,
responsibleDepartmentsText: event.target.value,
}))
}
placeholder="多个责任部门请用中文逗号分隔"
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/>
</div>
<div className="md:col-span-2">
<label className="mb-2 block text-sm font-medium text-slate-700">
问题简述
</label>
<textarea
rows={3}
value={formValues.brief}
onChange={(event) =>
setFormValues((current) => ({
...current,
brief: event.target.value,
}))
}
placeholder="请输入问题简述"
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/>
<Dialog open={showCreateModal} onClose={handleCloseCreateModal} size="5xl">
<DialogTitle>新增问题整改记录</DialogTitle>
<DialogDescription>
表单字段已按 <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs">problem_rectification</code>{' '}
对象定义渲染,`select` 选项直接读取对象配置,日期字段会在提交时自动转成 ISO 8601。
</DialogDescription>
<DialogBody>
<div className="rounded-[24px] border border-zinc-100 bg-zinc-50/50 px-4 py-3 text-sm text-slate-500">
至少填写“检查项目名称”或“问题简述”之一。长文本字段已展开为更适合录入的输入区域。
</div>
<div className="md:col-span-2">
<label className="mb-2 block text-sm font-medium text-slate-700">
问题发现情况
</label>
<textarea
rows={4}
value={formValues.detail}
onChange={(event) =>
setFormValues((current) => ({
...current,
detail: event.target.value,
}))
}
placeholder="请输入问题发现情况"
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/>
</div>
<div className="mt-6 grid gap-5 md:grid-cols-2">
{createFields.map((fieldDef) => {
const isLongText = fieldDef.type === 'long_text';
const isSingleColumn = !isLongText && fieldDef.name !== 'responsible_department';
<div className="md:col-span-2">
return (
<div
key={fieldDef.name}
className={clsx(
'rounded-[24px] border border-zinc-200 bg-white/90 p-4 shadow-sm',
isSingleColumn ? '' : 'md:col-span-2',
)}
>
<label className="mb-2 block text-sm font-medium text-slate-700">
整改情况
{fieldDef.label || fieldDef.name}
</label>
<textarea
rows={3}
value={formValues.rectificationSituation}
onChange={(event) =>
<CreateField
fieldDef={fieldDef}
value={formValues[fieldDef.name]}
onChange={(nextValue) =>
setFormValues((current) => ({
...current,
rectificationSituation: event.target.value,
[fieldDef.name]: nextValue,
}))
}
placeholder="请输入整改情况"
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/>
{FIELD_HINTS[fieldDef.name] ? (
<div className="mt-2 text-xs leading-5 text-slate-500">
{FIELD_HINTS[fieldDef.name]}
</div>
<div className="md:col-span-2">
<label className="mb-2 block text-sm font-medium text-slate-700">
备注
</label>
<textarea
rows={2}
value={formValues.remark}
onChange={(event) =>
setFormValues((current) => ({
...current,
remark: event.target.value,
}))
}
placeholder="请输入备注"
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-300 focus:bg-white"
/>
) : null}
</div>
);
})}
</div>
</ModalCard>
) : null}
</DialogBody>
<DialogActions>
<Button outline onClick={handleCloseCreateModal}>
取消
</Button>
<Button color="blue" disabled={actionLoading || !createSubject} onClick={handleCreate}>
{actionLoading ? '提交中...' : '确认新增'}
</Button>
</DialogActions>
</Dialog>
{showPushModal ? (
<ModalCard
title="推送问题整改表"
description={`已选择 ${selectedRecords.length} 条记录。`}
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => {
<Dialog
open={showPushModal}
onClose={() => {
setShowPushModal(false);
setFillerIds([]);
setAuditorIds([]);
setFillerUsers([]);
setAuditorUsers([]);
}}
className="rounded-2xl border border-zinc-200 px-4 py-2 text-sm font-medium text-slate-600"
>
取消
</button>
<button
type="button"
disabled={actionLoading}
onClick={handlePush}
className="rounded-2xl bg-[linear-gradient(135deg,#7f1d1d,#be123c)] px-5 py-2 text-sm font-medium text-white shadow-lg disabled:opacity-60"
>
{actionLoading ? '推送中...' : '确认推送'}
</button>
</div>
}
size="4xl"
>
<DialogTitle>推送问题整改表</DialogTitle>
<DialogDescription>
已选择 {selectedRecords.length} 条记录。请通过远程搜索选择填报人和复核人。
</DialogDescription>
<DialogBody>
<div className="grid gap-6 md:grid-cols-2">
<UserMultiSelect
<RemoteUserSelect
label="填报人"
value={fillerIds}
onChange={setFillerIds}
options={userOptions}
value={fillerUsers}
onChange={setFillerUsers}
onSearch={handleSearchUsers}
placeholder="搜索填报人用户名或显示名"
/>
<UserMultiSelect
<RemoteUserSelect
label="复核人"
value={auditorIds}
onChange={setAuditorIds}
options={userOptions}
value={auditorUsers}
onChange={setAuditorUsers}
onSearch={handleSearchUsers}
placeholder="搜索复核人用户名或显示名"
/>
</div>
<div className="mt-6 rounded-2xl border border-zinc-200 bg-zinc-50 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="mt-3 flex flex-wrap gap-2">
{selectedRecords.map((record) => (
<span
key={record.id}
className="rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm"
className="rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm ring-1 ring-zinc-200"
>
{record.projectName}
</span>
))}
</div>
</div>
</ModalCard>
</DialogBody>
<DialogActions>
<Button
outline
onClick={() => {
setShowPushModal(false);
setFillerUsers([]);
setAuditorUsers([]);
}}
>
取消
</Button>
<Button color="blue" disabled={actionLoading} onClick={handlePush}>
{actionLoading ? '推送中...' : '确认推送'}
</Button>
</DialogActions>
</Dialog>
) : null}
</div>
);
......
......@@ -225,8 +225,12 @@
- `auditor`
- `restriction_key = "_id"`
- `restriction_value = 选中问题记录 ID 列表`
- `reference_record_id = 选中问题记录 ID 列表`
- `subject_name = "problem_rectification"`
- `view_name = 当前筛选视图名`
- `editor_status = "未完成"`
- `auditor_status = "未完成"`
- `sender_status = "未完成"`
5. 批量更新选中问题的 `metadata.fill_in_the_people`
6. 发送内部通知与 OA 待办
......@@ -271,11 +275,12 @@
- 批量更新所有关联问题 `metadata.audit_status = "已审批"`
- 回写 `metadata.review_people`
- 创建最终审核待办给发起人
- 更新流程主单 `auditor_status = "已完成"`、`sender_status = "未审批"`
- 更新流程主单 `auditor_status = "已完成"`、`sender_status = "未完成"`
2. 驳回
- 批量更新所有关联问题 `metadata.audit_status = "已驳回"`
- 重新给填报人发通知和待办
- 更新流程主单 `editor_status = "未完成"`、`auditor_status = "已完成"`
- 重新写入 `progress_id_editor`,使流程回到填写阶段
推荐新交互:
......@@ -294,6 +299,7 @@
- 批量更新所有关联问题 `metadata.final_audit_status = "已驳回"`
- 重建填写人待办
- 更新流程主单 `editor_status = "未完成"`、`sender_status = "已完成"`
- 重新写入 `progress_id_editor`,使流程重新回到填写阶段
推荐新交互:
......@@ -323,13 +329,21 @@
### 8.2 流程主单状态字段
`message_reference_id` 中旧代码已使用:
`message_reference_id` 在当前项目中的约定:
- `sender` 记录推送发起人
- `editor` 记录填写人列表
- `auditor` 记录审核人列表
- `restriction_key` 默认固定为 `_id`
- `restriction_value` 保存本次推送绑定的整改记录 `_id[]`
- `reference_record_id` 作为关联记录 ID 冗余字段,和 `restriction_value` 一致
- `subject_name` 保存被推送记录的对象名,当前为 `problem_rectification`
- `progress_id_editor`、`progress_id_auditor`、`progress_id_sender` 记录每一步用户确认行为对应的流程 ID
- `editor_status`:`未完成`、`已完成`
- `auditor_status`:`未审批`、`已完成`
- `sender_status`:`未审批`、`已完成`、`已驳回`
- `auditor_status`:`未完成`、`已完成`
- `sender_status`:`未完成`、`已完成`
新项目可以继续兼容这些值,但 UI 层建议用更清晰的步骤条或状态标签展示,而不是直接暴露原字段名。
当前新页面不再依赖旧版 `未审批`、`已驳回` 之类流程主单状态,而是通过“状态字段 + 当前 progress_id”共同判断流程走向。驳回场景通过重建 `progress_id_editor` 并将 `editor_status` 重置为 `未完成` 来回到填写阶段。
---
......@@ -649,6 +663,7 @@ const result = await queryUsers(0, 20, {
- 功能入口
- 关键交互步骤
- 推送、填报、复核、最终审核流程
- `message_reference_id` 的当前字段协议与回流规则
- 驾驶舱统计口径
- 归档流程
- 状态变化与数据约束
......
import dayjs from 'dayjs';
import {
batchUpdateRecords,
createNotice,
executeAction,
loadRecord,
loadSubject,
loadUser,
queryRecords,
sendNotice,
updateRecord,
} from '@/services/DataService';
import { normalizeProblemRectificationRecord } from './problemRectificationListService';
import { normalizeText, toArray } from '@/utils/subjectView';
const MESSAGE_REFERENCE_SUBJECT = 'message_reference_id';
const DEFAULT_STATUS = '未完成';
const COMPLETED_STATUS = '已完成';
function toIdString(value) {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value.trim();
}
return String(value._id || value.id || value.name || '').trim();
}
function normalizeUserIds(value) {
return toArray(value)
.map((item) => toIdString(item))
.filter(Boolean);
}
function normalizeUserMap(users = []) {
return users.reduce((result, user) => {
const id = toIdString(user);
if (id) {
result[id] = user;
}
return result;
}, {});
}
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 [];
}
async function loadUsersByIds(ids = []) {
const uniqueIds = Array.from(new Set(ids.map((item) => toIdString(item)).filter(Boolean)));
const users = await Promise.all(
uniqueIds.map(async (id) => {
try {
return await loadUser(id);
} catch (error) {
return {
_id: id,
username: id,
display_name: id,
};
}
}),
);
return users.filter(Boolean);
}
function buildProgressFallback(step) {
return `${step}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
async function createProgressTasks({
recipients = [],
title,
currentUser,
returnUrl,
}) {
const tasks = [];
for (const recipient of recipients) {
try {
const result = await executeAction('oa_notice:createProgress', {
title,
classify: '流程待办',
contentType: '流程待办',
receiveUserId: recipient._id,
receiveUserName: recipient.username,
receiveDisplayName: recipient.display_name,
sendUserId: currentUser?._id || '',
sendUserName: currentUser?.username || '',
sendDisplayName: currentUser?.display_name || '',
returnUrl,
});
tasks.push(result?.data?.taskId || buildProgressFallback('progress'));
} catch (error) {
tasks.push(buildProgressFallback('progress'));
}
}
return tasks;
}
async function completeProgressTasks(progressIds = []) {
for (const progressId of toArray(progressIds).map((item) => normalizeText(item)).filter(Boolean)) {
try {
await executeAction('oa_notice:updateProgressStatus', {
taskId: progressId,
});
} catch (error) {
// OA 更新失败时不阻断主流程,保留流程主单状态作为最终依据。
}
}
}
async function sendFlowNotice({ title, content, fromUserId, toUserIds = [] }) {
const receivers = Array.from(new Set(toUserIds.map((item) => toIdString(item)).filter(Boolean)));
if (receivers.length === 0) {
return null;
}
const notice = await createNotice({
title,
content,
type: 'internal',
from: fromUserId || '',
to: receivers,
settings: {},
});
await sendNotice(notice.name);
return notice;
}
function buildReturnUrl(messageName) {
if (typeof window === 'undefined') {
return `/problem_rectification_form?id=${messageName}`;
}
return `${window.location.origin}/problem_rectification_form?id=${messageName}`;
}
function resolveCurrentStep(messageRecord) {
const metadata = messageRecord?.metadata || {};
const editorStatus = metadata.editor_status || DEFAULT_STATUS;
const auditorStatus = metadata.auditor_status || DEFAULT_STATUS;
const senderStatus = metadata.sender_status || DEFAULT_STATUS;
if (editorStatus !== COMPLETED_STATUS) {
return 'editor';
}
if (auditorStatus !== COMPLETED_STATUS) {
return 'auditor';
}
if (senderStatus !== COMPLETED_STATUS) {
return 'sender';
}
return 'completed';
}
function resolveViewerRole(messageRecord, currentUserId) {
if (!currentUserId) {
return 'guest';
}
const metadata = messageRecord?.metadata || {};
const senderId = toIdString(metadata.sender);
const editorIds = normalizeUserIds(metadata.editor);
const auditorIds = normalizeUserIds(metadata.auditor);
if (senderId && senderId === currentUserId) {
return 'sender';
}
if (editorIds.includes(currentUserId)) {
return 'editor';
}
if (auditorIds.includes(currentUserId)) {
return 'auditor';
}
return 'guest';
}
export async function loadProblemRectificationFlow(messageName, currentUser) {
const messageRecord = await loadRecord(messageName);
const metadata = messageRecord?.metadata || {};
const subjectName = metadata.subject_name || 'problem_rectification';
const restrictionValue = normalizeRestrictionValue(metadata.restriction_value);
const [subject, relatedResult, senderUsers, editorUsers, auditorUsers] = await Promise.all([
loadSubject(subjectName),
restrictionValue.length
? queryRecords({
subject: subjectName,
page: 0,
page_size: 1000,
sort: '-updated_at -created_at',
mode: 'full',
filter: {
_id: { $in: restrictionValue },
},
})
: Promise.resolve({ records: [] }),
loadUsersByIds([metadata.sender]),
loadUsersByIds(normalizeUserIds(metadata.editor)),
loadUsersByIds(normalizeUserIds(metadata.auditor)),
]);
const sender = senderUsers[0] || null;
const editorList = editorUsers || [];
const auditorList = auditorUsers || [];
const records = (relatedResult?.records || []).map((record) => normalizeProblemRectificationRecord(record));
const currentUserId = toIdString(currentUser?._id);
const currentStep = resolveCurrentStep(messageRecord);
const viewerRole = resolveViewerRole(messageRecord, currentUserId);
return {
messageRecord,
subject,
relatedRecords: records,
participants: {
sender,
editors: editorList,
auditors: auditorList,
senderMap: normalizeUserMap(sender ? [sender] : []),
editorMap: normalizeUserMap(editorList),
auditorMap: normalizeUserMap(auditorList),
},
currentStep,
viewerRole,
canEditRecords:
currentStep === 'editor' &&
(viewerRole === 'editor' || viewerRole === 'sender'),
};
}
export async function refreshRelatedProblemRecords(messageRecord) {
const metadata = messageRecord?.metadata || {};
const subjectName = metadata.subject_name || 'problem_rectification';
const restrictionValue = normalizeRestrictionValue(metadata.restriction_value);
if (restrictionValue.length === 0) {
return [];
}
const result = await queryRecords({
subject: subjectName,
page: 0,
page_size: 1000,
sort: '-updated_at -created_at',
mode: 'full',
filter: {
_id: { $in: restrictionValue },
},
});
return (result?.records || []).map((record) => normalizeProblemRectificationRecord(record));
}
export async function markEditorComplete({
messageRecord,
currentUser,
auditors,
}) {
const metadata = messageRecord?.metadata || {};
const messageName = messageRecord?.name || messageRecord?._id;
const returnUrl = buildReturnUrl(messageName);
const auditorProgressIds = await createProgressTasks({
recipients: auditors,
title: `${dayjs().format('YYYY-MM-DD')} 问题整改表复核`,
currentUser,
returnUrl,
});
await completeProgressTasks(metadata.progress_id_editor);
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
progress_id_auditor: auditorProgressIds,
editor_status: COMPLETED_STATUS,
auditor_status: DEFAULT_STATUS,
sender_status: metadata.sender_status || DEFAULT_STATUS,
},
});
await batchUpdateRecords({
subject: metadata.subject_name,
filter: {
_id: { $in: normalizeRestrictionValue(metadata.restriction_value) },
},
data: {
'metadata.review_people': auditors.map((item) => item._id),
},
});
await sendFlowNotice({
title: '问题整改表待复核',
content: `请点击链接复核问题整改表:${returnUrl}`,
fromUserId: currentUser?._id,
toUserIds: auditors.map((item) => item._id),
});
}
export async function approveByAuditor({
messageRecord,
currentUser,
sender,
}) {
const metadata = messageRecord?.metadata || {};
const messageName = messageRecord?.name || messageRecord?._id;
const returnUrl = buildReturnUrl(messageName);
const senderProgressIds = await createProgressTasks({
recipients: sender ? [sender] : [],
title: `${dayjs().format('YYYY-MM-DD')} 问题整改表最终审核`,
currentUser,
returnUrl,
});
await batchUpdateRecords({
subject: metadata.subject_name,
filter: {
_id: { $in: normalizeRestrictionValue(metadata.restriction_value) },
},
data: {
'metadata.review_people': [currentUser?._id].filter(Boolean),
'metadata.audit_status': '已审批',
},
});
await completeProgressTasks(metadata.progress_id_auditor);
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
progress_id_sender: senderProgressIds[0] || buildProgressFallback('sender'),
auditor_status: COMPLETED_STATUS,
sender_status: DEFAULT_STATUS,
},
});
await sendFlowNotice({
title: '问题整改表待最终审核',
content: `问题整改表已完成复核,请点击链接最终审核:${returnUrl}`,
fromUserId: currentUser?._id,
toUserIds: sender ? [sender._id] : [],
});
}
export async function rejectByAuditor({
messageRecord,
currentUser,
editors,
}) {
const metadata = messageRecord?.metadata || {};
const messageName = messageRecord?.name || messageRecord?._id;
const returnUrl = buildReturnUrl(messageName);
const editorProgressIds = await createProgressTasks({
recipients: editors,
title: `复核驳回,请重新填写问题整改表 ${dayjs().format('YYYY-MM-DD')}`,
currentUser,
returnUrl,
});
await batchUpdateRecords({
subject: metadata.subject_name,
filter: {
_id: { $in: normalizeRestrictionValue(metadata.restriction_value) },
},
data: {
'metadata.review_people': [currentUser?._id].filter(Boolean),
'metadata.audit_status': '已驳回',
},
});
await completeProgressTasks(metadata.progress_id_auditor);
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
progress_id_editor: editorProgressIds,
editor_status: DEFAULT_STATUS,
auditor_status: COMPLETED_STATUS,
sender_status: DEFAULT_STATUS,
},
});
await sendFlowNotice({
title: '问题整改表复核驳回',
content: `问题整改表复核未通过,请点击链接重新填写:${returnUrl}`,
fromUserId: currentUser?._id,
toUserIds: editors.map((item) => item._id),
});
}
export async function approveBySender({
messageRecord,
}) {
const metadata = messageRecord?.metadata || {};
await batchUpdateRecords({
subject: metadata.subject_name,
filter: {
_id: { $in: normalizeRestrictionValue(metadata.restriction_value) },
},
data: {
'metadata.final_audit_status': '已审批',
},
});
await completeProgressTasks(metadata.progress_id_sender);
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
sender_status: COMPLETED_STATUS,
},
});
}
export async function rejectBySender({
messageRecord,
currentUser,
editors,
}) {
const metadata = messageRecord?.metadata || {};
const messageName = messageRecord?.name || messageRecord?._id;
const returnUrl = buildReturnUrl(messageName);
const editorProgressIds = await createProgressTasks({
recipients: editors,
title: `最终审核驳回,请重新填写问题整改表 ${dayjs().format('YYYY-MM-DD')}`,
currentUser,
returnUrl,
});
await batchUpdateRecords({
subject: metadata.subject_name,
filter: {
_id: { $in: normalizeRestrictionValue(metadata.restriction_value) },
},
data: {
'metadata.final_audit_status': '已驳回',
},
});
await completeProgressTasks(metadata.progress_id_sender);
await updateRecord(messageRecord._id || messageRecord.name, {
metadata: {
progress_id_editor: editorProgressIds,
editor_status: DEFAULT_STATUS,
auditor_status: COMPLETED_STATUS,
sender_status: COMPLETED_STATUS,
},
});
await sendFlowNotice({
title: '问题整改表最终审核驳回',
content: `问题整改表最终审核未通过,请点击链接重新填写:${returnUrl}`,
fromUserId: currentUser?._id,
toUserIds: editors.map((item) => item._id),
});
}
......@@ -4,6 +4,7 @@ import {
loadSubject,
queryRecords,
} from '@/services/DataService';
import { DEPARTMENT_GROUPS } from './problemRectificationDashboardService';
import {
buildViewColumns,
emptyValueForField,
......@@ -60,16 +61,25 @@ function formatDateText(value) {
export function normalizeProblemRectificationRecord(record) {
const metadata = record?.metadata || {};
const dueDate = metadata.the_time_of_rectification
? dayjs(metadata.the_time_of_rectification)
: null;
return {
id: record?._id || record?.name || '',
name: record?.name || '',
title: normalizeText(record?.title) || normalizeText(metadata.project_name),
projectName: normalizeText(metadata.project_name) || '-',
inspectUnit: normalizeText(metadata.inspect_the_implementing_unit),
progress: normalizeText(metadata.rectification_progress),
feedbackUnit: normalizeText(metadata.feedback_unit),
auditStatus: normalizeText(metadata.audit_status),
finalAuditStatus: normalizeText(metadata.final_audit_status),
problemType: normalizeText(metadata.problem_type),
problemFrom: normalizeText(metadata.problem_from),
relatedIssueDomain: normalizeText(metadata.related_issue_domain),
brief: normalizeText(metadata.problem_brief),
dueDate: dueDate && dueDate.isValid() ? dueDate : null,
fillUsers: toArray(metadata.fill_in_the_people)
.map(normalizeUserLabel)
.filter(Boolean),
......@@ -190,6 +200,90 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
};
}
function matchesDepartmentGroup(record, groupKey) {
if (!groupKey || groupKey === 'all') {
return true;
}
const matchedGroup = DEPARTMENT_GROUPS.find((group) => group.key === groupKey);
if (!matchedGroup) {
return true;
}
if (!matchedGroup.matchFeedbackUnits || matchedGroup.matchFeedbackUnits.length === 0) {
return true;
}
return matchedGroup.matchFeedbackUnits.includes(record.feedbackUnit);
}
function buildSearchText(record) {
return [
record.projectName,
record.inspectUnit,
record.feedbackUnit,
record.progress,
record.auditStatus,
record.finalAuditStatus,
record.problemType,
record.problemFrom,
record.relatedIssueDomain,
record.brief,
record.dueDateLabel,
record.fillUsers.join(' '),
record.reviewUsers.join(' '),
]
.map((value) => normalizeText(value))
.filter(Boolean)
.join(' ')
.toLowerCase();
}
export function buildProblemRectificationListView(records = [], filters = {}) {
const keyword = normalizeText(filters.keyword).toLowerCase();
const progress = normalizeText(filters.progress);
const auditStatus = normalizeText(filters.auditStatus);
const groupKey = normalizeText(filters.groupKey) || 'all';
const scopedRecords = records.filter((record) => {
if (progress && record.progress !== progress) {
return false;
}
if (auditStatus && record.auditStatus !== auditStatus) {
return false;
}
if (keyword && !buildSearchText(record).includes(keyword)) {
return false;
}
return true;
});
const groupCounts = Object.fromEntries(
DEPARTMENT_GROUPS.map((group) => [
group.key,
scopedRecords.filter((record) => matchesDepartmentGroup(record, group.key)).length,
]),
);
const filteredRecords = scopedRecords.filter((record) =>
matchesDepartmentGroup(record, groupKey),
);
return {
filteredRecords,
groupCounts,
progressOptions: Array.from(
new Set(records.map((item) => item.progress).filter(Boolean)),
),
auditStatusOptions: Array.from(
new Set(records.map((item) => item.auditStatus).filter(Boolean)),
),
};
}
export function buildProblemRectificationCreateFormFields(gridView, subject) {
const columns = buildViewColumnsForList(gridView, subject)
.filter((column) => column.field !== 'title')
......
......@@ -239,25 +239,32 @@ type Record = {
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 |
| --- | --- | --- | --- | --- | --- |
| 发起人 | `sender` | user | 否 | 否 | 是 |
| 填写人 | `editor` | user | 否 | 是 | 是 |
| 审核人 | `auditor` | user | 否 | 是 | 是 |
| 限制字段 | `restriction_key` | text | 否 | 否 | 是 |
| 限制值列表 | `restriction_value` | text | 否 | 是 | 是 |
| 发送人 | `sender` | user | 否 | 否 | 否 |
| 填写人 | `editor` | user | 否 | 是 | 否 |
| 审核人 | `auditor` | user | 否 | 是 | 否 |
| 约束 key | `restriction_key` | text | 否 | 否 | 否 |
| 约束值 | `restriction_value` | text | 否 | 是 | 否 |
| 对象名 | `subject_name` | text | 否 | 否 | 否 |
| 视图名称 | `view_name` | text | 否 | 否 | 否 |
| 主题名称 | `subject_name` | text | 否 | 否 | 是 |
| 关联记录 ID | `reference_record_id` | text | 否 | 是 | 否 |
| 填写待办 ID | `progress_id_editor` | text | 否 | 是 | 否 |
| 审核待办 ID | `progress_id_auditor` | text | 否 | 是 | 否 |
| 最终审核待办 ID | `progress_id_sender` | text | 否 | 否 | 否 |
| 填写状态 | `editor_status` | select | 否 | 否 | 否 |
| 审核状态 | `auditor_status` | select | 否 | 否 | 否 |
| 发起人状态 | `sender_status` | select | 否 | 否 | 否 |
### 3.4.1 旧流程状态值
- `editor_status`:旧页面使用过 `未完成`、`已完成`
- `auditor_status`:旧页面使用过 `未审批`、`已完成`
- `sender_status`:旧页面使用过 `未审批`、`已完成`、`已驳回`
| 发送人状态 | `sender_status` | select | 否 | 否 | 否 |
### 3.4.1 当前流程写入约定
- `restriction_key` 默认写 `_id`
- `restriction_value` 保存被推送整改记录的 `_id[]`
- `reference_record_id` 与 `restriction_value` 保持一致,作为显式的“关联记录 ID”冗余字段
- `subject_name` 保存被推送记录所属对象名,当前整改流程固定为 `problem_rectification`
- `progress_id_editor`、`progress_id_auditor`、`progress_id_sender` 用于记录每一步用户确认动作生成的流程 ID,用来区分当前流程推进到了哪一步
- `editor_status`:当前项目统一使用 `未完成`、`已完成`
- `auditor_status`:当前项目统一使用 `未完成`、`已完成`
- `sender_status`:当前项目统一使用 `未完成`、`已完成`
- 当复核或最终审核驳回时,流程主单会回写新的 `progress_id_editor`,并将 `editor_status` 重置为 `未完成`,从而重新进入填写阶段
## 3.5 项目管理
......
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