safety-eval-service-frontend/src/pages/Container/InspNoticeManage/InspectionNotice/index.js

780 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useEffect, useState } from "react";
import {
Form,
Table,
Button,
Modal,
Input,
Select,
ConfigProvider,
message,
Upload,
} from "antd";
import { UploadOutlined } from "@ant-design/icons";
import PageLayout from "@cqsjjb/jjb-react-admin-component/PageLayout";
import SearchForm from "~/components/SearchForm";
import ControlWrapper from "@cqsjjb/jjb-react-admin-component/ControlWrapper";
import { AntdTableFuncControl } from "@cqsjjb/jjb-common-decorator/antd";
import { Connect } from "@cqsjjb/jjb-dva-runtime";
import { NS_INSP_NOTICE } from "~/enumerate/namespace";
import dayjs from "dayjs";
import "./index.less";
const { TextArea } = Input;
const API_HOST = window.process?.env?.app?.API_HOST || "";
const UPLOAD_ACTION = `${API_HOST}/safetyEval/file/upload`;
/** 雪花 ID 须保持字符串,避免 Number() 精度丢失 */
const toId = (id) => (id == null ? id : String(id));
const downloadBinaryPost = async (path, body, fallbackName) => {
const headers = {
"Content-Type": "application/json",
token: sessionStorage.getItem("token") || "",
};
const orgInfoId = sessionStorage.getItem("orgInfoId");
if (orgInfoId) {
headers.orgInfoId = String(orgInfoId);
}
const res = await fetch(`${API_HOST}${path}`, {
method: "POST",
headers,
body: JSON.stringify(body || {}),
});
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const json = await res.json();
throw new Error(json.errMessage || json.message || "导出失败");
}
if (!res.ok) {
throw new Error(`请求失败(${res.status})`);
}
const blob = await res.blob();
let fileName = fallbackName;
const disposition = res.headers.get("content-disposition") || "";
const matched = disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)/i);
if (matched?.[1]) {
try {
fileName = decodeURIComponent(matched[1].replace(/"/g, ""));
} catch {
fileName = matched[1].replace(/"/g, "");
}
}
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = fileName;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
};
const PROTO_THEME = {
token: { borderRadius: 8, borderRadiusLG: 12, borderRadiusSM: 6 },
};
/** 通知类型与监管端监督检查告知一致 */
const INSP_TYPE_OPTIONS = [
{ label: "项目过程检查", value: "PROJECT_PROCESS" },
{ label: "资质保持检查", value: "QUAL_KEEP" },
{ label: "专项检查", value: "SPECIAL" },
];
const TAB_STATUS = {
all: undefined,
unread: 1,
processing: 2,
feedback: 3,
closed: 4,
};
const TAB_ITEMS = [
{ key: "all", label: "全部通知" },
{ key: "unread", label: "待处理" },
{ key: "processing", label: "处理中" },
{ key: "feedback", label: "待监管复核" },
{ key: "closed", label: "已办结" },
];
const STATUS_FILTER_OPTIONS = [
{ label: "待处理", value: 1 },
{ label: "处理中", value: 2 },
{ label: "待监管复核", value: 3 },
{ label: "已办结", value: 4 },
];
const PROCESS_TYPE_OPTIONS = [
{ label: "提交整改材料", value: "提交整改材料" },
{ label: "提交核查说明", value: "提交核查说明" },
{ label: "补充证明材料", value: "补充证明材料" },
{ label: "查看办结结果", value: "查看办结结果" },
];
const statusTagClass = (code) => {
if (code === 1) return "inst-tag-danger";
if (code === 2) return "inst-tag-warning";
if (code === 3) return "inst-tag-info";
if (code === 4) return "inst-tag-success";
return "";
};
const statusDisplayName = (code, name) => {
if (code === 1) return "待处理";
if (code === 2) return "处理中";
if (code === 3) return "待监管复核";
if (code === 4) return "已办结";
return name || "-";
};
/** 列表「通知类型」展示:办结用办结通知,其余用检查类型名 */
const noticeTypeLabel = (record) => {
if (record.statusCode === 4) return "办结通知";
return record.inspTypeName || "监督检查通知";
};
const noticeTypeTagClass = (record) => {
if (record.statusCode === 4) return "inst-tag-success";
if (record.statusCode === 1) return "inst-tag-danger";
if (record.statusCode === 3) return "inst-tag-info";
return "inst-tag-warning";
};
const formatDate = (val) => {
if (!val) return "-";
const d = dayjs(val);
return d.isValid() ? d.format("YYYY-MM-DD") : val;
};
const resolveFileUrl = (raw) => {
if (!raw) return "";
const u = String(raw);
if (/^https?:\/\//i.test(u)) return u;
const base = window.fileUrl || "";
return base ? `${base}${u}` : u;
};
const parseUrlList = (val) => {
if (!val) return [];
if (Array.isArray(val)) return val.filter(Boolean);
if (typeof val === "string") {
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) return parsed.filter(Boolean);
} catch {
/* ignore */
}
return val
.split(/[,;\n]/)
.map((s) => s.trim())
.filter(Boolean);
}
return [];
};
const urlToFileItem = (url) => ({
fileUrl: url,
fileName: String(url).split("?")[0].split("/").pop() || "附件",
});
const collectViewFiles = (detail, materials = []) => {
const fromMaterials = (materials || []).filter((m) => m.fileUrl);
const fromNotice = parseUrlList(detail?.noticeUrls).map(urlToFileItem);
const fromResult = parseUrlList(detail?.resultUrls).map(urlToFileItem);
const seen = new Set();
return [...fromMaterials, ...fromNotice, ...fromResult].filter((item) => {
const key = resolveFileUrl(item.fileUrl);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
};
const materialFileName = (m) =>
m.fileName || m.materialName || m.name || String(m.fileUrl || "").split("?")[0].split("/").pop() || "附件";
const ModalSection = ({ title, desc, children }) => (
<div className="inst-modal-section">
<h4>{title}</h4>
{desc ? <p>{desc}</p> : null}
{children}
</div>
);
const InstitutionInspectionNotice = (props) => {
const [searchForm] = Form.useForm();
const [processForm] = Form.useForm();
const [dataSource, setDataSource] = useState([]);
const [total, setTotal] = useState(0);
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [activeTab, setActiveTab] = useState("all");
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [metrics, setMetrics] = useState({
total: 0,
pending: 0,
feedback: 0,
closed: 0,
});
const [processOpen, setProcessOpen] = useState(false);
const [processViewOnly, setProcessViewOnly] = useState(false);
const [detail, setDetail] = useState(null);
const [materials, setMaterials] = useState([]);
const [uploadUrls, setUploadUrls] = useState([]);
const [exportOpen, setExportOpen] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const {
institutionInspNoticePageLoading,
institutionInspNoticeGetLoading,
institutionInspNoticeConfirmLoading,
institutionInspNoticeFeedbackLoading,
institutionInspNoticeMaterialUploadLoading,
} = props;
const buildQuery = (tab = activeTab) => {
const values = searchForm.getFieldsValue();
const tabStatus = TAB_STATUS[tab];
return {
keyword: values.keyword || undefined,
inspTypeCode: values.inspTypeCode || undefined,
statusCode: values.statusCode ?? tabStatus,
};
};
const fetchMetrics = async () => {
try {
const [allRes, pendingRes, feedbackRes, closedRes] = await Promise.all([
props.institutionInspNoticePage({ current: 1, size: 1 }),
props.institutionInspNoticePage({ current: 1, size: 1, statusCode: 1 }),
props.institutionInspNoticePage({ current: 1, size: 1, statusCode: 3 }),
props.institutionInspNoticePage({ current: 1, size: 1, statusCode: 4 }),
]);
setMetrics({
total: allRes?.total || 0,
pending: pendingRes?.total || 0,
feedback: feedbackRes?.total || 0,
closed: closedRes?.total || 0,
});
} catch {
/* ignore */
}
};
const fetchData = async (page = pageIndex, size = pageSize, tab = activeTab) => {
const res = await props.institutionInspNoticePage({
...buildQuery(tab),
current: page,
size,
});
if (res?.success !== false) {
setDataSource(res?.data || []);
setTotal(res?.total || 0);
setPageIndex(page);
setPageSize(size);
}
};
useEffect(() => {
fetchData(1, 10, "all");
fetchMetrics();
}, []);
const switchTab = (key) => {
setActiveTab(key);
searchForm.setFieldsValue({ statusCode: undefined });
fetchData(1, pageSize, key);
};
const openProcess = async (record, viewOnly = false) => {
setProcessViewOnly(viewOnly);
setProcessOpen(true);
setDetail(null);
setMaterials([]);
setUploadUrls([]);
processForm.resetFields();
processForm.setFieldsValue({
noticeNo: record.noticeNo,
processType: "提交整改材料",
processRemark: "请根据监管要求填写问题原因、处理措施和完成情况。",
});
const [detailRes, matRes] = await Promise.all([
props.institutionInspNoticeGet({ id: String(record.id) }),
props.institutionInspNoticeMaterials({ noticeId: toId(record.id) }),
]);
if (detailRes?.success !== false) {
const data = detailRes?.data || null;
setDetail(data);
processForm.setFieldsValue({ noticeNo: data?.noticeNo || record.noticeNo });
if (data?.statusCode === 4) {
processForm.setFieldsValue({ processType: "查看办结结果" });
}
} else {
}
if (matRes?.success !== false) {
setMaterials(matRes?.data || []);
}
};
const onFileUpload = (info) => {
if (info.file.status === "done") {
const responseData = info.file.response?.data || info.file.response || {};
const fileUrl = responseData?.url || responseData?.fileUrl || "";
if (!fileUrl) {
message.error("上传成功但未返回文件地址");
return;
}
setUploadUrls((prev) => [...prev, fileUrl]);
} else if (info.file.status === "error") {
message.error("上传失败");
}
};
const handleSubmit = async () => {
if (!detail?.id) return;
const values = await processForm.validateFields();
if (values.processType === "查看办结结果" || detail.statusCode === 4) {
setProcessOpen(false);
return;
}
let current = detail;
if (current.statusCode === 1) {
const confirmRes = await props.institutionInspNoticeConfirm({
id: toId(current.id),
});
if (confirmRes?.success === false) {
return;
}
current = confirmRes?.data || { ...current, statusCode: 2 };
}
const pendingMaterials = materials.filter(
(m) => m.uploadStatusCode !== 2 && !m.fileUrl,
);
const urls = [...uploadUrls];
for (let i = 0; i < Math.min(urls.length, pendingMaterials.length); i += 1) {
const mat = pendingMaterials[i];
const upRes = await props.institutionInspNoticeMaterialUpload({
materialId: toId(mat.id),
fileUrl: urls[i],
});
if (upRes?.success === false) {
return;
}
}
if (current.statusCode === 2 || detail.statusCode === 2 || detail.statusCode === 1) {
const matRes = await props.institutionInspNoticeMaterials({
noticeId: toId(detail.id),
});
const latest = matRes?.data || materials;
const feedbackRes = await props.institutionInspNoticeFeedback({
id: toId(detail.id),
materials: latest
.filter((m) => m.fileUrl || urls.length)
.map((m, idx) => ({
materialId: toId(m.id),
fileUrl: m.fileUrl || urls[idx] || urls[0],
}))
.filter((m) => m.fileUrl),
});
if (feedbackRes?.success === false) {
return;
}
}
message.success("处理材料已提交监管复核");
setProcessOpen(false);
fetchData(pageIndex, pageSize);
fetchMetrics();
};
const handleExport = async () => {
setExportLoading(true);
try {
const query = buildQuery();
const hasSelected = selectedRowKeys.length > 0;
const body = {
...query,
scope: hasSelected ? "SELECTED" : "FILTERED",
ids: hasSelected ? selectedRowKeys.map(toId) : undefined,
};
await downloadBinaryPost(
"/safetyEval/institution/insp-notice/export-messages",
body,
"监管消息清单.csv",
);
message.success("监管消息清单已导出");
setExportOpen(false);
} catch (e) {
message.error(e.message || "导出失败");
} finally {
setExportLoading(false);
}
};
const columns = [
{
title: "通知编号",
dataIndex: "noticeNo",
width: 140,
render: (t) => <span className="inst-mono">{t || "-"}</span>,
},
{
title: "通知类型",
dataIndex: "inspTypeName",
width: 110,
render: (_, record) => (
<span className={`inst-tag ${noticeTypeTagClass(record)}`}>
{noticeTypeLabel(record)}
</span>
),
},
{
title: "来源预警",
dataIndex: "sourceRefNo",
width: 140,
render: (t) => t || "-",
},
{
title: "来源字段",
key: "sourceFields",
width: 180,
render: (_, record) => (
<div className="inst-source-list">
{record.sourceTypeName ? (
<span className="inst-source-pill">{record.sourceTypeName}</span>
) : (
<span className="inst-source-pill">监督检查</span>
)}
{record.inspTypeName ? (
<span className="inst-source-pill">{record.inspTypeName}</span>
) : null}
</div>
),
},
{
title: "监管要求",
dataIndex: "noticeContent",
ellipsis: true,
width: 240,
},
{
title: "办理期限",
dataIndex: "planInspTime",
width: 120,
render: formatDate,
},
{
title: "状态",
dataIndex: "statusCode",
width: 110,
render: (code, record) => (
<span className={`inst-tag ${statusTagClass(code)}`}>
{statusDisplayName(code, record.statusName)}
</span>
),
},
{
title: "操作",
key: "action",
width: 90,
align: "center",
fixed: "right",
render: (_, record) =>
record.statusCode === 1 ? (
<Button type="primary" size="small" onClick={() => openProcess(record)}>
处理
</Button>
) : (
<Button type="primary" size="small" onClick={() => openProcess(record, true)}>
查看
</Button>
),
},
];
const submitting =
institutionInspNoticeConfirmLoading ||
institutionInspNoticeFeedbackLoading ||
institutionInspNoticeMaterialUploadLoading;
return (
<ConfigProvider theme={PROTO_THEME}>
<PageLayout title="监管通知管理">
<div className="inst-page">
<div className="inst-page-header">
<div className="inst-sub">
接收监管端下发的整改通知监督检查通知核查函及其他通知文件机构在线反馈结果上传证明材料并查看监管复核意见
</div>
<div className="inst-tabs">
{TAB_ITEMS.map((tab) => (
<button
key={tab.key}
type="button"
className={`inst-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => switchTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="inst-metrics">
<div className="inst-metric-card">
<div>
<div className="label">监管消息总数</div>
<div className="value">{metrics.total}</div>
<div className="hint">来源监管端风险预警中心</div>
</div>
<div className="icon"></div>
</div>
<div className="inst-metric-card">
<div>
<div className="label">待处理</div>
<div className="value danger">{metrics.pending}</div>
<div className="hint">需机构直接处理</div>
</div>
<div className="icon"></div>
</div>
<div className="inst-metric-card">
<div>
<div className="label">待监管复核</div>
<div className="value warning">{metrics.feedback}</div>
<div className="hint">材料已提交</div>
</div>
<div className="icon"></div>
</div>
<div className="inst-metric-card">
<div>
<div className="label">已办结</div>
<div className="value success">{metrics.closed}</div>
<div className="hint">办结消息可归档</div>
</div>
<div className="icon"></div>
</div>
</div>
<div className="inst-note">
<strong>消息来源</strong>
监管端风险预警中心监管人员在风险预警中心发出整改通知核查函补正通知或办结结果后机构端在此接收并处理资质保持监控页面只作为监管信息来源
</div>
<SearchForm
form={searchForm}
loading={institutionInspNoticePageLoading}
style={{ marginBottom: 16 }}
formLine={[
<Form.Item key="keyword" name="keyword">
<ControlWrapper.Input
label="通知关键词"
placeholder="通知编号/预警编号/事项"
allowClear
/>
</Form.Item>,
<Form.Item key="inspTypeCode" name="inspTypeCode">
<ControlWrapper.Select
label="通知类型"
placeholder="全部"
allowClear
options={INSP_TYPE_OPTIONS}
/>
</Form.Item>,
<Form.Item key="statusCode" name="statusCode">
<ControlWrapper.Select
label="处理状态"
placeholder="全部"
allowClear
options={STATUS_FILTER_OPTIONS}
/>
</Form.Item>,
]}
onFinish={() => fetchData(1, pageSize)}
onReset={() => {
searchForm.resetFields();
setActiveTab("all");
fetchData(1, pageSize, "all");
}}
/>
<div className="inst-toolbar">
<div className="inst-toolbar-left">
<span className="inst-hint">
监管消息列表展示来源预警来源字段办理期限和机构处理状态
</span>
</div>
<div className="inst-toolbar-right">
<Button size="small" onClick={() => setExportOpen(true)}>
导出消息
</Button>
</div>
</div>
<Table
rowKey="id"
columns={columns}
dataSource={dataSource}
loading={institutionInspNoticePageLoading}
scroll={{ y: props.scrollY, x: 1200 }}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: 34,
}}
pagination={{
total,
current: pageIndex,
pageSize,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `${t}`,
}}
onChange={(pag) => fetchData(pag.current, pag.pageSize)}
/>
</div>
{/* 处理 — 对齐原型 regMessageProcess */}
<Modal
title={
detail
? `${processViewOnly ? "监管消息查看" : "监管消息处理"} - ${detail.noticeNo}`
: processViewOnly
? "监管消息查看"
: "监管消息处理"
}
open={processOpen}
onCancel={() => setProcessOpen(false)}
width={820}
destroyOnHidden
className="inst-modal"
footer={
processViewOnly ? (
<Button onClick={() => setProcessOpen(false)}>关闭</Button>
) : (
<Button type="primary" loading={submitting} onClick={handleSubmit}>
提交
</Button>
)
}
>
{institutionInspNoticeGetLoading || !detail ? (
<div className="inst-loading">加载中...</div>
) : (
<>
<ModalSection
title="处理要求"
desc="机构收到消息后直接填写说明并上传证明材料,完成后提交监管复核。"
/>
<Form
form={processForm}
layout="vertical"
preserve={false}
scrollToFirstError
>
<div className="inst-form-grid">
<Form.Item name="noticeNo" label="关联消息编号">
<Input readOnly />
</Form.Item>
<Form.Item name="processType" label="处理方式">
<Select options={PROCESS_TYPE_OPTIONS} disabled={processViewOnly} />
</Form.Item>
<Form.Item
name="processRemark"
label="处理说明"
className="inst-form-full"
>
<TextArea rows={3} disabled={processViewOnly} />
</Form.Item>
<Form.Item
label={processViewOnly ? "附件" : "上传附件"}
className="inst-form-full"
>
{processViewOnly ? (
(() => {
const viewFiles = collectViewFiles(detail, materials);
return viewFiles.length ? (
viewFiles.map((m) => (
<a
key={m.id || m.fileUrl}
onClick={() =>
window.open(resolveFileUrl(m.fileUrl), "_blank")
}
style={{
display: "block",
color: "#1677ff",
cursor: "pointer",
}}
>
{materialFileName(m)}
</a>
))
) : (
<span>暂无附件</span>
);
})()
) : (
<>
<Upload
name="file"
action={UPLOAD_ACTION}
headers={{
token: sessionStorage.getItem("token") || "",
}}
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png"
multiple
onChange={onFileUpload}
>
<Button icon={<UploadOutlined />}>选择文件</Button>
</Upload>
<div className="inst-upload-tip">
支持 PDFWordExcel图片可选择多个文件
</div>
</>
)}
</Form.Item>
</div>
</Form>
</>
)}
</Modal>
{/* 导出 — 对齐原型 regMessageExport */}
<Modal
title="监管消息清单"
open={exportOpen}
onCancel={() => setExportOpen(false)}
width={560}
destroyOnHidden
className="inst-modal"
footer={
<Button type="primary" loading={exportLoading} onClick={handleExport}>
确认导出
</Button>
}
>
<ModalSection
title="导出内容"
desc="包含消息编号、来源预警、来源字段、监管要求、办理期限、处理状态和办结结果。"
/>
</Modal>
</PageLayout>
</ConfigProvider>
);
};
export default Connect(
[NS_INSP_NOTICE],
true,
)(AntdTableFuncControl(InstitutionInspectionNotice));