Commit 93b2457f authored by drigle's avatar drigle

update: 推送流程

parent 858cfe78
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const result = {};
let navigations = [];
const origin = 'https://example.test';
const location = { origin, pathname: '/integrity_practice_management_new/self_inspection_form', href: origin + '/integrity_practice_management_new/self_inspection_form?id=flow%26one&type=editor#row2', replace: url => navigations.push(url) };
vm.runInNewContext(ts.transpileModule(fs.readFileSync('src/utils/authRedirect.js','utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText, {exports:result, URL, window:{location}, require:()=>({APPLICATION_BASE_PATH:'/integrity_practice_management_new/',ROUTE_TITLES:{'/notifications':'通知'},SELF_INSPECTION_FLOW_PATH:'/self_inspection_form'})});
const target = '/self_inspection_form?id=flow%26one&type=editor#row2';
assert.equal(result.normalizeAuthRedirect(location.href), target);
assert.equal(result.normalizeAuthRedirect(target), target);
assert.equal(new URLSearchParams(result.getLoginRoute(location.href).split('?')[1]).get('redirect'),target);
for(const input of ['https://evil.test/self_inspection_form','//evil.test/self_inspection_form','javascript:alert(1)','/login?redirect=/login','/another_app/form']) assert.equal(result.normalizeAuthRedirect(input),'/');
result.redirectToLogin();
assert.equal(navigations.length,1);
assert.ok(navigations[0].startsWith('/integrity_practice_management_new/login?redirect='));
location.pathname='/integrity_practice_management_new/login';
result.redirectToLogin();
assert.equal(navigations.length,1);
console.log('PASS: deep links, query/hash, base normalization, external/login-loop rejection, 401 redirect');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const source = fs.readFileSync('src/pages/integrity_practice_management/selfInspectionFlowService.js', 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
let message, rows, parents, calls, failure, denyField;
const clone = (value) => JSON.parse(JSON.stringify(value));
const subject = { fields: [{ name: 'self_inspection_result', isRequired: true }, { name: 'attachment', multiple: true }] };
const api = {
loadRecord: async (id) => clone(id === 'p1' ? parents[0] : message),
loadSubject: async () => clone(subject),
loadUser: async (id) => ({ _id: id, username: id, display_name: id }),
queryRecords: async ({ subject }) => ({ records: clone(subject === 'self_check_the_project_results' ? rows : parents) }),
updateRecord: async (id, data) => {
calls.push(['update', id, clone(data)]);
if (failure === 'save' && id === 'r1') throw Error('save failed');
if (id === 'm1') Object.assign(message.metadata, data.metadata);
},
batchUpdateRecords: async (data) => { calls.push(['batch', clone(data)]); },
executeAction: async (action, data) => {
calls.push([action, clone(data)]);
if (failure === 'oa') throw Error('oa failed');
return { data: { taskId: `task-${calls.length}` } };
},
createNotice: async (data) => { calls.push(['notice', clone(data)]); return { name: 'notice' }; },
sendNotice: async () => { if (failure === 'notice') throw Error('notice failed'); },
};
const exportsObject = {};
vm.runInNewContext(compiled, { exports: exportsObject, require(name) {
if (name.endsWith('DataService')) return api;
if (name.endsWith('subjectView')) return { getSelectOptions: () => [], isFieldDisabledByView: (field) => field === denyField };
if (name.endsWith('application')) return { getSelfInspectionFlowUrl: (name) => `https://test/integrity_practice_management_new/self_inspection_form?id=${name}` };
return { createIntegrityPracticeMaterial: async () => { calls.push(['material']); return { id: 'material1' }; } };
}});
const service = exportsObject;
function reset() {
message = { _id: 'm1', name: 'M001', subject: { name: 'message_reference_id' }, metadata: {
subject_name: 'self_check_the_project_results', restriction_value: ['r1'], reference_record_id: ['p1'],
editor: ['editor', 'editor2'], auditor: ['auditor', 'auditor2'], sender: 'sender',
editor_status: '未完成', auditor_status: '未完成', sender_status: '未完成', progress_id_editor: ['old-editor'],
}};
rows = [{ _id: 'r1', metadata: { self_inspection_result: '已检查' } }];
parents = [{ _id: 'p1', subject: { name: 'self_check_the_results_of_the_working_draft' }, metadata: { project_outcome: [{ _id: 'r1' }] } }]; calls = []; failure = null; denyField = null;
}
const user = (id) => ({ _id: id, username: id });
const transition = (action, id) => service.transitionSelfInspectionFlow({ messageName: 'M001', currentUser: user(id), action });
(async () => {
reset();
await assert.rejects(() => service.loadSelfInspectionFlow('M001', user('guest')), /参与人/);
message.metadata.subject_name = 'problem_rectification';
await assert.rejects(() => service.loadSelfInspectionFlow('M001', user('editor')), /不是廉洁/);
reset();
await assert.rejects(() => transition('approve', 'auditor'), /状态已变化/);
assert.equal(calls.length, 0);
await transition('submit', 'editor');
assert.equal(message.metadata.editor_status, '已完成');
assert.equal(calls.filter(([name]) => name === 'oa_notice:createProgress').length, 2);
assert.ok(calls.find(([name, value]) => name === 'notice' && value.content.includes('/integrity_practice_management_new/')));
await transition('withdraw', 'editor2');
assert.equal(message.metadata.editor_status, '未完成');
assert.equal(calls.filter(([name]) => name === 'notice').length, 1);
await transition('submit', 'editor');
await transition('reject', 'auditor2');
assert.equal(message.metadata.editor_status, '未完成');
assert.ok(calls.some(([name, value]) => name === 'batch' && value.data['metadata.review_status'] === '已驳回'));
assert.ok(!calls.some(([name, value]) => name === 'batch' && value.data['metadata.status'] === '已驳回'));
await transition('submit', 'editor');
await transition('approve', 'auditor');
await assert.rejects(() => transition('withdraw', 'editor'), /状态已变化/);
await transition('finalReject', 'sender');
await transition('submit', 'editor');
assert.equal(message.metadata.sender_status, '未完成');
await transition('approve', 'auditor');
await transition('finalApprove', 'sender');
assert.equal(service.getFlowPermissions(message, user('sender')).step, 'completed');
await assert.rejects(() => transition('finalApprove', 'sender'), /状态已变化/);
reset();
message.metadata.editor.push('sender');
assert.equal(service.getFlowPermissions(message, user('sender')).canSubmit, true);
await assert.rejects(() => service.saveSelfInspectionResults({ messageName: 'M001', currentUser: user('editor'), changes: { alien: { self_inspection_result: 'bad' } } }), /以外/);
rows[0].metadata.self_inspection_result = '';
await assert.rejects(() => transition('submit', 'editor'), /请填写/);
assert.equal(calls.length, 0);
reset();
assert.equal(service.getFlowPermissions(message, user('auditor')).canEdit, true);
message.metadata.editor_status = '已完成';
assert.equal(service.getFlowPermissions(message, user('editor')).canEdit, false);
assert.equal(service.getFlowPermissions(message, user('auditor')).canEdit, true);
message.metadata.auditor_status = '已完成';
assert.equal(service.getFlowPermissions(message, user('auditor')).canEdit, false);
assert.equal(service.getFlowPermissions(message, user('sender')).canEdit, true);
reset(); denyField = 'metadata.self_inspection_result';
await assert.rejects(() => service.saveSelfInspectionResults({ messageName: 'M001', currentUser: user('editor'), changes: { r1: { self_inspection_result: 'unauthorized' } } }), /视图不允许/);
assert.equal(calls.length, 0);
reset(); message.metadata.restriction_value = [];
const recovered = await service.loadSelfInspectionFlow('M001', user('editor'));
assert.equal(recovered.rows[0]._id, 'r1');
assert.equal(calls.length, 0, 'reading an old flow must not write');
await service.saveSelfInspectionResults({ messageName: 'M001', currentUser: user('editor'), changes: { r1: { self_inspection_result: 'recovered' } } });
await transition('submit', 'editor');
assert.ok(calls.some(([kind,data]) => kind === 'batch' && data.filter._id.$in.includes('r1')));
reset(); parents[0].metadata.project_outcome = ['other'];
assert.equal((await service.loadSelfInspectionFlow('M001',user('editor'))).rows[0]._id,'r1', 'explicit flow IDs remain authoritative');
reset(); message.metadata.restriction_value = []; parents[0].metadata.project_outcome = [];
await assert.rejects(() => service.loadSelfInspectionFlow('M001', user('editor')), /均没有自查项目/);
reset();
assert.equal(service.getFlowApprovalStatus(message, '已审批'), '未审批');
assert.equal(service.getFlowApprovalStatus(message, '已审批', true), '未审批');
message.metadata.editor_status = '已完成';
assert.equal(service.getFlowApprovalStatus(message, '已审批'), '未审批');
message.metadata.auditor_status = '已完成';
assert.equal(service.getFlowApprovalStatus(message, '已审批'), '已审批');
assert.equal(service.getFlowApprovalStatus(message, '已审批', true), '未审批');
message.metadata.sender_status = '已完成';
assert.equal(service.getFlowApprovalStatus(message, '已审批', true), '已审批');
message.metadata.editor_status = '未完成';
assert.equal(service.getFlowApprovalStatus(message, '已驳回', true), '已驳回');
assert.equal(service.getFlowApprovalStatus(message, '已审批'), '未审批');
reset(); failure = 'oa';
await assert.rejects(() => transition('submit', 'editor'), /oa failed/);
assert.equal(message.metadata.editor_status, '未完成');
reset(); failure = 'notice';
await assert.rejects(() => transition('submit', 'editor'), /状态已更新/);
assert.equal(message.metadata.editor_status, '已完成');
reset(); failure = 'save';
const pending = { r1: [{ fileAsset: { url: '/file.pdf' }, materialRecordId: null }] };
const save = () => service.saveSelfInspectionResults({ messageName: 'M001', currentUser: user('editor'), changes: { r1: { attachment: [{ name: 'file.pdf', url: '/file.pdf' }] } }, pendingMaterials: pending });
await assert.rejects(save, /save failed/); failure = null; await save();
assert.equal(calls.filter(([name]) => name === 'material').length, 1);
assert.equal(pending.r1[0].materialRecordId, 'material1');
console.log('PASS: permissions, wrong subject, transitions, withdrawal, rejection/resubmission, terminal state, required fields, OA failure, partial notification failure, material retry');
})().catch((error) => { console.error(error); process.exitCode = 1; });
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const ts = require('typescript');
const exportsObject = {};
let writes = [], empty = false;
const toArray = value => value == null ? [] : Array.isArray(value) ? value : [value];
const api = {
loadRecord: async id => ({ _id: id, name: id, title: id, metadata: { project_outcome: empty && id === 'p2' ? [] : [{ _id: 'r1' }, 'r2', 'r2'] } }),
getSubjectView: async () => [{ name: 'view' }],
batchUpdateRecords: async value => writes.push(['batch', value]),
updateRecord: async (...args) => writes.push(['update', ...args]),
createRecord: async value => { writes.push(['create', value]); return { _id: 'flow', name: 'flow' }; },
loadUser: async id => ({ _id: id, username: id }),
executeAction: async (...args) => { writes.push(['oa', ...args]); return { data: { taskId: 'task' } }; },
createNotice: async value => { writes.push(['notice', value]); return { name: 'notice' }; },
sendNotice: async () => {},
};
const compiled = ts.transpileModule(fs.readFileSync('src/pages/integrity_practice_management/integrityPracticeWorkbenchService.js', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
vm.runInNewContext(compiled, { exports: exportsObject, require(name) {
if (name.endsWith('DataService')) return api;
if (name.endsWith('subjectView')) return { toArray };
if (name.endsWith('application')) return { getSelfInspectionFlowUrl: () => '/flow' };
if (name === 'dayjs') return { default: () => ({ format: () => '2026-09-20' }) };
return {};
}});
const push = records => exportsObject.executeConfiguredAction('integrityRiskManagerService.pushSelfInspectionForm', { selectedRecords: records, userInfo: { _id: 'sender' }, assignment: { fillers: ['editor'], auditors: ['auditor'] } });
(async () => {
await push([{ _id: 'p1', metadata: {} }]);
const created = writes.find(([kind]) => kind === 'create')[1];
assert.equal(JSON.stringify(created.metadata.restriction_value), '["r1","r2"]');
writes = []; empty = true;
await assert.rejects(() => push([{ _id: 'p1' }, { _id: 'p2' }]), /没有关联自查项目/);
assert.equal(writes.length, 0);
console.log('PASS: push hydrates omitted list references, normalizes IDs, and validates entire batch before any writes');
})().catch(error => { console.error(error); process.exitCode = 1; });
import { Reload, Info } from '@/components/AppIcons';
import { DataTable, DataTableText, DataTableTextPair } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { Alert, AlertDialog, Button, Card, Chip, Spinner, TextArea, Toast, Tooltip, toast } from '@heroui/react';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'umi';
import IntegrityPracticeMaterialField from './components/IntegrityPracticeMaterialField';
import {
flowIds, getEditableResultFields, getFlowApprovalStatus, loadSelfInspectionFlow,
saveSelfInspectionResults, toFlowId, transitionSelfInspectionFlow,
} from './selfInspectionFlowService';
const ACTION_LABELS = {
submit: '提交审核', withdraw: '撤回提交', approve: '审核通过', reject: '审核驳回',
finalApprove: '最终通过', finalReject: '最终驳回',
};
const STEP_LABELS = { editor: '待填写', auditor: '待审核', sender: '待最终审核', completed: '已完成' };
function StatusChip({ value }) {
return <Chip size="sm" color={value === '已审批' || value === '已完成' ? 'success' : value === '已驳回' ? 'danger' : 'warning'}>{value || '未审批'}</Chip>;
}
export default function SelfInspectionFlowPage() {
const currentUser = useContext(UserContext);
const [searchParams] = useSearchParams();
const messageName = searchParams.get('id') || '';
const [flow, setFlow] = useState(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [changes, setChanges] = useState({});
const [validation, setValidation] = useState({});
const [confirmAction, setConfirmAction] = useState(null);
const [session, setSession] = useState(0);
const [compact, setCompact] = useState(() => window.innerWidth < 768);
const pendingMaterials = useRef({});
const busyRef = useRef(false);
const requestVersion = useRef(0);
const reload = useCallback(async () => {
const version = ++requestVersion.current;
setLoading(true);
try {
const result = await loadSelfInspectionFlow(messageName, currentUser);
if (version !== requestVersion.current) return;
setFlow(result);
setChanges({});
setValidation({});
pendingMaterials.current = {};
setSession((value) => value + 1);
} catch (requestError) {
if (version === requestVersion.current) {
setFlow(null);
setError(requestError?.message || '自查底稿加载失败,请重试。');
}
} finally {
if (version === requestVersion.current) setLoading(false);
}
}, [messageName, currentUser?._id]);
useEffect(() => {
setError('');
setFlow(null);
reload();
return () => { requestVersion.current += 1; };
}, [reload]);
useEffect(() => { document.title = '自查底稿填写与审核 - 廉洁从业管理'; }, []);
useEffect(() => {
const media = window.matchMedia('(max-width: 767px)');
const update = () => setCompact(media.matches);
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
useEffect(() => {
if (!Object.keys(changes).length) return undefined;
const guard = (event) => { event.preventDefault(); event.returnValue = ''; };
window.addEventListener('beforeunload', guard);
return () => window.removeEventListener('beforeunload', guard);
}, [changes]);
const permissions = flow?.permissions || {};
const rows = flow?.rows || [];
const metadata = flow?.messageRecord?.metadata || {};
const editableFields = getEditableResultFields(flow?.subject, flow?.view, currentUser);
const resultField = editableFields.find((field) => field.name === 'self_inspection_result');
const attachmentField = editableFields.find((field) => field.name === 'attachment');
const hasValidation = Object.values(validation).some(Boolean);
const disabled = busy || loading || hasValidation;
const hasChanges = Object.keys(changes).length > 0;
const userLabel = (ids) => flowIds(ids).map((id) => {
const user = flow?.users.find((item) => toFlowId(item) === id);
return user?.display_name || user?.username || id;
}).join('、') || '未配置';
const draftFor = (row) => ({ ...row.metadata, ...changes[row._id] });
const changeField = (id, field, value) => setChanges((previous) => ({
...previous, [id]: { ...previous[id], [field]: value },
}));
const run = async (action, row) => {
if (busyRef.current || hasValidation) return;
busyRef.current = true;
setBusy(true);
setError('');
try {
if (action === 'save' || (hasChanges && action !== 'withdraw')) {
const updates = row ? { [row._id]: changes[row._id] || {} } : changes;
await saveSelfInspectionResults({ messageName, currentUser, changes: updates, pendingMaterials: pendingMaterials.current });
if (action === 'save') {
// Preserve unsaved edits in other rows when saving one row.
setFlow((previous) => ({ ...previous, rows: previous.rows.map((item) => updates[item._id]
? { ...item, metadata: { ...item.metadata, ...updates[item._id] } } : item) }));
setChanges((previous) => Object.fromEntries(Object.entries(previous).filter(([id]) => !Object.prototype.hasOwnProperty.call(updates, id))));
toast.success('自查结果已保存', { timeout: 3500 });
return;
}
}
await transitionSelfInspectionFlow({ messageName, currentUser, action });
toast.success(`${ACTION_LABELS[action]}成功`, { timeout: 3500 });
setConfirmAction(null);
await reload();
} catch (requestError) {
setError(requestError?.message || '操作失败,请稍后重试。');
setConfirmAction(null);
// Keep local drafts on failure; refresh explicitly after checking partial writes.
} finally {
busyRef.current = false;
setBusy(false);
}
};
const columns = [
{ key: 'project', title: '自查项目', minWidth: 240, width: 240, isPrimary: true,
render: (row) => <DataTableTextPair primary={row.metadata?.project || row.title} secondary={row.metadata?.inspection_item} /> },
{ key: 'content', title: '自查要点及关注风险点', minWidth: 300, width: 300,
render: (row) => <DataTableText text={row.metadata?.content} showTooltip maxLines={3} /> },
{ key: 'self_inspection_result', title: <span>自查结果{resultField?.isRequired || resultField?.required ? <span className="text-danger"> *</span> : null}</span>, minWidth: 320, width: 320,
render: (row) => permissions.canEdit && resultField ? (
<TextArea aria-label={`${row.metadata?.project || row.title} 自查结果`} rows={4} variant="secondary"
isDisabled={busy} isRequired={Boolean(resultField.isRequired || resultField.required)}
value={draftFor(row).self_inspection_result || ''}
onChange={(event) => changeField(row._id, 'self_inspection_result', event.target.value)} />
) : <DataTableText text={row.metadata?.self_inspection_result} showTooltip maxLines={3} /> },
{ key: 'attachment', title: '附件', minWidth: 300, width: 300,
render: (row) => permissions.canEdit && attachmentField ? (
<div inert={busy ? true : undefined}>
<IntegrityPracticeMaterialField key={`${session}-${row._id}`} editSessionId={session}
fieldPath={row._id} multiple={Boolean(attachmentField.multiple)} value={draftFor(row).attachment}
onChange={(value) => changeField(row._id, 'attachment', value)}
onValidationChange={(id, message) => setValidation((previous) => ({ ...previous, [id]: message }))}
onPendingLocalMaterialChange={(id, change) => {
const current = pendingMaterials.current[id] || [];
pendingMaterials.current[id] = change.type === 'replace' ? change.materials
: change.type === 'remove' ? current.filter((item) => item.clientId !== change.clientId)
: [...new Map([...current, ...change.materials].map((item) => [item.clientId, item])).values()];
}} />
</div>
) : <div className="space-y-2">{(Array.isArray(row.metadata?.attachment) ? row.metadata.attachment : [row.metadata?.attachment]).filter(Boolean).map((file, index) => (
<a key={`${file.url}-${index}`} className="block truncate text-accent" href={file.url} target="_blank" rel="noreferrer">{file.name || '查看附件'}</a>
))}</div> },
{ key: 'status', title: '审批状态', minWidth: 180, width: 180,
render: (row) => <div className="space-y-2"><div className="flex items-center justify-between gap-2"><span className="text-xs text-muted">审核</span><StatusChip value={getFlowApprovalStatus(flow?.messageRecord, row.metadata?.status, false)} /></div><div className="flex items-center justify-between gap-2"><span className="text-xs text-muted">最终审核</span><StatusChip value={getFlowApprovalStatus(flow?.messageRecord, row.metadata?.final_status, true)} /></div></div> },
...(permissions.canEdit ? [{ key: 'actions', title: '操作', minWidth: 104, width: 104, isActions: true,
render: (row) => <Button size="sm" variant="secondary" isDisabled={disabled || !changes[row._id]} onPress={() => run('save', row)}>保存</Button> }] : []),
];
const availableActions = [
permissions.canSubmit && 'submit', permissions.canWithdraw && 'withdraw',
permissions.canAudit && 'reject', permissions.canAudit && 'approve',
permissions.canFinalAudit && 'finalReject', permissions.canFinalAudit && 'finalApprove',
].filter(Boolean);
return (
<main className="min-h-dvh min-w-0 overflow-x-hidden bg-background px-4 py-4 text-foreground sm:px-6 sm:py-6 lg:px-12">
<div className="flex min-w-0 flex-col gap-6">
<Card><Card.Content className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-3"><h1 className="text-lg font-medium">自查底稿填写与审核</h1>{flow ? <StatusChip value={STEP_LABELS[permissions.step]} /> : null}</div>
<dl className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div><dt className="inline text-muted">流程编号:</dt><dd className="inline break-all">{messageName || '未提供'}</dd></div>
{flow ? <><div><dt className="inline text-muted">填报人:</dt><dd className="inline">{userLabel(metadata.editor)}</dd></div><div><dt className="inline text-muted">审核人:</dt><dd className="inline">{userLabel(metadata.auditor)}</dd></div><div><dt className="inline text-muted">发起人:</dt><dd className="inline">{userLabel(metadata.sender)}</dd></div></> : null}
</dl>
{flow?.drafts.map((draft) => <div className="flex flex-wrap items-center gap-3 text-sm" key={draft._id}><span>{draft.title}</span><span className="text-muted">{draft.metadata?.actual_filling_department}</span><span>审核:<StatusChip value={getFlowApprovalStatus(flow?.messageRecord, draft.metadata?.review_status, false)} /></span><span>最终审核:<StatusChip value={getFlowApprovalStatus(flow?.messageRecord, draft.metadata?.final_review_status, true)} /></span></div>)}
</div>
<Tooltip><Button aria-label="刷新流程" isIconOnly size="sm" variant="secondary" isDisabled={disabled} onPress={() => {
if (hasChanges) setConfirmAction('refresh'); else { setError(''); reload(); }
}}><Reload size={16} /></Button><Tooltip.Content>刷新流程</Tooltip.Content></Tooltip>
</Card.Content></Card>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
{loading ? <div className="grid min-h-56 place-items-center" role="status" aria-label="正在加载自查底稿"><Spinner /></div> : flow ? (
<section className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex items-center gap-1.5"><h2 className="text-base font-medium">自查项目</h2><Tooltip><Button isIconOnly aria-label="填写说明" size="sm" variant="ghost"><Info size={14} /></Button><Tooltip.Content>在表格中填写自查结果并保存;提交审核会保存当前全部修改。审核与最终审核按整份底稿处理。</Tooltip.Content></Tooltip></div>
<div className="flex flex-wrap items-center gap-2">
{permissions.canEdit ? <Button size="sm" variant="secondary" isDisabled={disabled || !hasChanges} onPress={() => run('save')}>保存全部</Button> : null}
{availableActions.map((action) => <Button key={action} size="sm" variant={action.toLowerCase().includes('reject') ? 'danger-soft' : action === 'withdraw' ? 'secondary' : 'primary'} isDisabled={disabled || !rows.length} onPress={() => setConfirmAction(action)}>{ACTION_LABELS[action]}</Button>)}
</div>
</div>
<DataTable ariaLabel="廉洁从业自查项目" columns={columns} rows={rows} pinColumns={!compact} getRowKey={(row) => row._id} emptyTitle="当前流程没有关联的自查项目" />
</section>
) : null}
</div>
<AlertDialog isOpen={Boolean(confirmAction)} onOpenChange={(open) => { if (!open && !busy) setConfirmAction(null); }}>
<AlertDialog.Backdrop isKeyboardDismissDisabled={busy}><AlertDialog.Container size="md"><AlertDialog.Dialog>
<AlertDialog.Header><AlertDialog.Heading>{confirmAction === 'refresh' ? '放弃未保存的修改?' : `确认${ACTION_LABELS[confirmAction] || '操作'}?`}</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body>{confirmAction === 'refresh' ? '刷新后将重新加载已保存的数据。' : confirmAction === 'submit' ? '将保存当前修改并把整份底稿交给审核人,提交后不能继续填写。' : confirmAction?.toLowerCase().includes('reject') ? '整份底稿将退回填报人重新填写。' : confirmAction === 'withdraw' ? '撤回后恢复填写,审核人的当前待办将结束。' : '本次操作作用于当前流程关联的全部自查项目。'}</AlertDialog.Body>
<AlertDialog.Footer><Button variant="secondary" isDisabled={busy} onPress={() => setConfirmAction(null)}>取消</Button><Button variant={confirmAction?.toLowerCase().includes('reject') ? 'danger' : 'primary'} isPending={busy} isDisabled={busy} onPress={() => {
if (confirmAction === 'refresh') { setConfirmAction(null); setError(''); reload(); } else run(confirmAction);
}}>确认</Button></AlertDialog.Footer>
</AlertDialog.Dialog></AlertDialog.Container></AlertDialog.Backdrop>
</AlertDialog>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</main>
);
}
import {
batchUpdateRecords, createNotice, executeAction, loadRecord, loadSubject,
loadUser, queryRecords, sendNotice, updateRecord, getSubjectViewDetail,
} from '@/services/DataService';
import { getSelectOptions, isFieldDisabledByView } from '@/utils/subjectView';
import { getSelfInspectionFlowUrl } from '@/config/application';
import { createIntegrityPracticeMaterial } from './integrityPracticeMaterialService';
export const RESULT_SUBJECT = 'self_check_the_project_results';
export const DRAFT_SUBJECT = 'self_check_the_results_of_the_working_draft';
const PENDING = '未完成';
const COMPLETED = '已完成';
const EDITABLE_FIELDS = ['self_inspection_result', 'attachment'];
// Business records can retain results from an earlier push. Only show a
// decision when the corresponding node of THIS workflow has handled it.
export function getFlowApprovalStatus(messageRecord, value, final = false) {
const metadata = messageRecord?.metadata || {};
const handled = metadata[final ? 'sender_status' : 'auditor_status'] === COMPLETED;
if (!handled) return '未审批';
if (metadata.editor_status !== COMPLETED) {
return value === '已驳回' ? '已驳回' : '未审批';
}
if (final && metadata.auditor_status !== COMPLETED) return '未审批';
return value || '未审批';
}
export function toFlowId(value) {
return typeof value === 'object' && value ? String(value._id || value.name || '') : String(value || '');
}
export function flowIds(value) {
const values = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : value ? [value] : [];
return [...new Set(values.map(toFlowId).map((id) => id.trim()).filter(Boolean))];
}
export function getFlowPermissions(messageRecord, currentUser) {
const metadata = messageRecord?.metadata || {};
const id = toFlowId(currentUser);
const isEditor = Boolean(id) && flowIds(metadata.editor).includes(id);
const isAuditor = Boolean(id) && flowIds(metadata.auditor).includes(id);
const isSender = Boolean(id) && toFlowId(metadata.sender) === id;
const step = metadata.editor_status !== COMPLETED ? 'editor'
: metadata.auditor_status !== COMPLETED ? 'auditor'
: metadata.sender_status !== COMPLETED ? 'sender' : 'completed';
return {
step, isEditor, isAuditor, isSender,
canView: isEditor || isAuditor || isSender,
// Preserve the original integrity page's editing roles, independently of action roles.
canEdit: isSender || (isEditor ? metadata.editor_status !== COMPLETED
: isAuditor && (metadata.editor_status !== COMPLETED || metadata.auditor_status !== COMPLETED)),
canSubmit: step === 'editor' && isEditor,
canWithdraw: step === 'auditor' && isEditor,
canAudit: step === 'auditor' && isAuditor,
canFinalAudit: step === 'sender' && isSender,
};
}
async function readMessage(messageName, currentUser) {
if (!messageName) throw new Error('缺少流程 ID,无法加载自查底稿。');
const messageRecord = await loadRecord(messageName);
if (toFlowId(messageRecord?.subject?.name || messageRecord?.subject) !== 'message_reference_id'
|| messageRecord?.metadata?.subject_name !== RESULT_SUBJECT) {
throw new Error('该链接不是廉洁从业自查底稿流程。');
}
const permissions = getFlowPermissions(messageRecord, currentUser);
if (!permissions.canView) throw new Error('您不是当前流程的参与人,无法查看或处理该底稿。');
if (!flowIds(messageRecord.metadata.restriction_value).length) {
const drafts = await Promise.all(flowIds(messageRecord.metadata.reference_record_id).map((id) => loadRecord(id)));
if (drafts.some((draft) => (draft.subject?.name || draft.subject) !== DRAFT_SUBJECT)) {
throw new Error('流程关联的记录不是自查底稿填写表。');
}
messageRecord.metadata.restriction_value = flowIds(drafts.flatMap((draft) => draft.metadata?.project_outcome || []));
if (!messageRecord.metadata.restriction_value.length) throw new Error('当前流程及关联底稿均没有自查项目,请检查底稿的项目结果,无需重复推送。');
}
return { messageRecord, permissions };
}
async function queryRelated(subject, ids) {
const records = [];
// Query in bounded chunks rather than silently omitting rows beyond the API limit.
for (let offset = 0; offset < ids.length; offset += 1000) {
const batch = ids.slice(offset, offset + 1000);
const result = await queryRecords({ subject, page: 0, page_size: 1000, mode: 'full', filter: { _id: { $in: batch } } });
records.push(...(result?.records || []));
}
const byId = new Map(records.map((record) => [record._id, record]));
if (ids.some((id) => !byId.has(id))) throw new Error('部分关联记录不存在或无权读取,请联系流程发起人。');
return ids.map((id) => byId.get(id));
}
export async function loadSelfInspectionFlow(messageName, currentUser) {
const { messageRecord, permissions } = await readMessage(messageName, currentUser);
const metadata = messageRecord.metadata;
const participantIds = flowIds([metadata.sender, ...flowIds(metadata.editor), ...flowIds(metadata.auditor)]);
const [subject, rows, drafts, users, view] = await Promise.all([
loadSubject(RESULT_SUBJECT),
queryRelated(RESULT_SUBJECT, flowIds(metadata.restriction_value)),
queryRelated(DRAFT_SUBJECT, flowIds(metadata.reference_record_id)),
Promise.all(participantIds.map(async (id) => {
try { return await loadUser(id); } catch { return { _id: id, display_name: id }; }
})),
metadata.view_name ? getSubjectViewDetail(RESULT_SUBJECT, metadata.view_name) : Promise.resolve(null),
]);
return { messageRecord, permissions, subject, rows, drafts, users, view };
}
async function assertAction(messageName, currentUser, permission) {
const flow = await readMessage(messageName, currentUser);
if (!flow.permissions[permission]) throw new Error('流程状态已变化或您无权执行此操作,请刷新后重试。');
if (!flowIds(flow.messageRecord.metadata.restriction_value).length) throw new Error('该流程没有关联的自查项目,无法提交或审批。');
return flow.messageRecord;
}
export function getEditableResultFields(subject, view, currentUser) {
return (subject?.fields || []).filter((field) => EDITABLE_FIELDS.includes(field.name)
&& !field.isPrimaryKey && !field.primary_key
&& (view === undefined || !isFieldDisabledByView(`metadata.${field.name}`, view, currentUser)));
}
function validatePatch(patch, subject) {
const fields = getEditableResultFields(subject);
const metadata = {};
for (const field of fields) {
if (!Object.prototype.hasOwnProperty.call(patch, field.name)) continue;
const value = patch[field.name];
const empty = value == null || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && !value.length);
if ((field.isRequired || field.required) && empty) throw new Error(`请填写${field.label || field.name}。`);
if (field.name === 'attachment') {
const files = Array.isArray(value) ? value : value ? [value] : [];
if (files.some((file) => !file?.url)) throw new Error('附件缺少有效地址,请重新上传。');
const normalized = files.map(({ name, type, size, url }) => ({ name, type, size, url }));
metadata.attachment = field.multiple ? normalized : normalized[0] || null;
} else {
metadata[field.name] = String(value ?? '');
}
}
return metadata;
}
export async function saveSelfInspectionResults({ messageName, currentUser, changes, pendingMaterials = {} }) {
const messageRecord = await assertAction(messageName, currentUser, 'canEdit');
const allowedIds = new Set(flowIds(messageRecord.metadata.restriction_value));
const subject = await loadSubject(RESULT_SUBJECT);
const view = messageRecord.metadata.view_name
? await getSubjectViewDetail(RESULT_SUBJECT, messageRecord.metadata.view_name) : null;
const writableFields = new Set(getEditableResultFields(subject, view, currentUser).map((field) => field.name));
const updates = Object.entries(changes).map(([id, patch]) => {
if (!allowedIds.has(id)) throw new Error('不能修改当前流程以外的自查项目。');
if (Object.keys(patch).some((field) => !writableFields.has(field))) throw new Error('当前视图不允许修改该字段,请刷新后重试。');
return { id, metadata: validatePatch(patch, subject) };
});
for (const { id, metadata } of updates) {
const retainedUrls = new Set((Array.isArray(metadata.attachment) ? metadata.attachment : [metadata.attachment]).filter(Boolean).map((file) => file.url));
for (const material of pendingMaterials[id] || []) {
if (!retainedUrls.has(material.fileAsset?.url) || material.materialRecordId) continue;
const created = await createIntegrityPracticeMaterial({ fileAsset: material.fileAsset, currentUser });
// Keep successful material IDs through a partial failure so retry does not create duplicates.
material.materialRecordId = created.id;
}
if (Object.keys(metadata).length) await updateRecord(id, { metadata });
}
}
async function createTasks(recipientIds, currentUser, messageRecord, title) {
if (!recipientIds.length) throw new Error('流程未配置下一步处理人,请联系发起人。');
const taskIds = [];
for (const id of recipientIds) {
const recipient = await loadUser(id);
const response = await executeAction('oa_notice:createProgress', {
title, classify: '流程待办', contentType: '流程待办',
receiveUserId: id, receiveUserName: recipient.username, receiveDisplayName: recipient.display_name,
sendUserId: currentUser._id, sendUserName: currentUser.username, sendDisplayName: currentUser.display_name,
returnUrl: getSelfInspectionFlowUrl(messageRecord.name || messageRecord._id),
});
if (!response?.data?.taskId) throw new Error('OA 未返回有效的待办 ID,流程尚未完成,请联系管理员核对待办。');
taskIds.push(response.data.taskId);
}
return taskIds;
}
async function completeTasks(value) {
for (const taskId of flowIds(value)) await executeAction('oa_notice:updateProgressStatus', { taskId });
}
async function notify(messageRecord, currentUser, recipients, title) {
const notice = await createNotice({
title, content: `请点击链接处理自查底稿:${getSelfInspectionFlowUrl(messageRecord.name || messageRecord._id)}`,
type: 'internal', from: currentUser._id, to: recipients, settings: {},
});
await sendNotice(notice.name);
}
async function updateBusinessRecords(metadata, resultData, draftData) {
if (Object.keys(resultData).length) {
await batchUpdateRecords({ subject: RESULT_SUBJECT, filter: { _id: { $in: flowIds(metadata.restriction_value) } }, data: resultData });
}
const draftIds = flowIds(metadata.reference_record_id);
if (draftIds.length && Object.keys(draftData).length) {
await batchUpdateRecords({ subject: DRAFT_SUBJECT, filter: { _id: { $in: draftIds } }, data: draftData });
}
}
export async function transitionSelfInspectionFlow({ messageName, currentUser, action }) {
const permission = {
submit: 'canSubmit', withdraw: 'canWithdraw', approve: 'canAudit', reject: 'canAudit',
finalApprove: 'canFinalAudit', finalReject: 'canFinalAudit',
}[action];
if (!permission) throw new Error('不支持的流程操作。');
const messageRecord = await assertAction(messageName, currentUser, permission);
const metadata = messageRecord.metadata;
let patch;
let recipients = [];
let title = '';
if (action === 'withdraw') {
await completeTasks(metadata.progress_id_auditor);
patch = { editor_status: PENDING, auditor_status: PENDING, progress_id_auditor: [] };
} else if (action === 'submit') {
// Validate the persisted complete dataset as well as edited rows.
const [subject, rows] = await Promise.all([
loadSubject(RESULT_SUBJECT), queryRelated(RESULT_SUBJECT, flowIds(metadata.restriction_value)),
]);
rows.forEach((row) => validatePatch(Object.fromEntries(EDITABLE_FIELDS.map((field) => [field, row.metadata?.[field]])), subject));
recipients = flowIds(metadata.auditor);
title = '廉洁从业自查底稿待审核';
const tasks = await createTasks(recipients, currentUser, messageRecord, title);
await updateBusinessRecords(metadata,
{ 'metadata.review_people': recipients, 'metadata.status': '未审批', 'metadata.final_status': '未审批' },
{ 'metadata.auditor': recipients, 'metadata.review_status': '未审批', 'metadata.final_review_status': '未审批' });
await completeTasks(metadata.progress_id_editor);
patch = { editor_status: COMPLETED, auditor_status: PENDING, sender_status: PENDING, progress_id_auditor: tasks };
} else if (action === 'approve') {
recipients = flowIds(metadata.sender);
title = '廉洁从业自查底稿待最终审核';
const tasks = await createTasks(recipients, currentUser, messageRecord, title);
await updateBusinessRecords(metadata,
{ 'metadata.status': '已审批', 'metadata.review_people': flowIds(metadata.auditor) },
{ 'metadata.review_status': '已审批', 'metadata.auditor': flowIds(metadata.auditor) });
await completeTasks(metadata.progress_id_auditor);
patch = { auditor_status: COMPLETED, sender_status: PENDING, progress_id_sender: tasks[0] };
} else if (action === 'finalApprove') {
await updateBusinessRecords(metadata, { 'metadata.final_status': '已审批' }, { 'metadata.final_review_status': '已审批' });
await completeTasks(metadata.progress_id_sender);
patch = { sender_status: COMPLETED };
} else {
const final = action === 'finalReject';
recipients = flowIds(metadata.editor);
title = `廉洁从业自查底稿${final ? '最终审核' : '审核'}驳回,请重新填写`;
const tasks = await createTasks(recipients, currentUser, messageRecord, title);
const subject = await loadSubject(RESULT_SUBJECT);
const statusOptions = getSelectOptions(subject.fields?.find((field) => field.name === 'status'));
// Legacy schemas allowed 已驳回; the current snapshot allows only 未审批 / 已审批.
const rejectedStatus = statusOptions.some((option) => option.value === '已驳回') ? '已驳回' : '未审批';
await updateBusinessRecords(metadata,
final ? { 'metadata.final_status': '已驳回' } : { 'metadata.status': rejectedStatus, 'metadata.review_people': flowIds(metadata.auditor) },
final ? { 'metadata.final_review_status': '已驳回' } : { 'metadata.review_status': '已驳回', 'metadata.auditor': flowIds(metadata.auditor) });
await completeTasks(final ? metadata.progress_id_sender : metadata.progress_id_auditor);
patch = { editor_status: PENDING, auditor_status: COMPLETED, sender_status: final ? COMPLETED : PENDING, progress_id_editor: tasks };
}
await updateRecord(messageRecord._id, { metadata: patch });
if (recipients.length) {
try { await notify(messageRecord, currentUser, recipients, title); }
catch (error) { throw new Error(`流程状态已更新,但站内通知发送失败:${error?.message || '请联系管理员核对通知'}。请刷新查看当前状态。`); }
}
}
import { APPLICATION_BASE_PATH, ROUTE_TITLES, SELF_INSPECTION_FLOW_PATH } from '@/config/application';
// Router navigation uses paths without the deployment base; browser navigation
// uses the base exactly once. Keep query/hash intact throughout the round trip.
export function normalizeAuthRedirect(value, origin = window.location.origin) {
if (!value) return '/';
try {
const target = new URL(value, origin);
if (target.origin !== origin) return '/';
const base = APPLICATION_BASE_PATH.replace(/\/+$/, '');
let path = target.pathname;
if (path === base) path = '/';
else if (path.startsWith(`${base}/`)) path = path.slice(base.length);
path = path.replace(/\/+$/, '') || '/';
if (path !== '/' && path !== SELF_INSPECTION_FLOW_PATH && !Object.hasOwn(ROUTE_TITLES, path)) return '/';
return `${path}${target.search}${target.hash}`;
} catch {
return '/';
}
}
export function getLoginRoute(value, origin = window.location.origin) {
return `/login?redirect=${encodeURIComponent(normalizeAuthRedirect(value, origin))}`;
}
export function redirectToLogin() {
const base = APPLICATION_BASE_PATH.replace(/\/+$/, '');
// A failed login/captcha request must not reload login or nest redirect URLs.
if (window.location.pathname.replace(/\/+$/, '') === `${base}/login`
|| window.location.pathname === '/login') return;
window.location.replace(`${base}${getLoginRoute(window.location.href)}`);
}
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