Commit dd0aab32 authored by David Yang's avatar David Yang

feat: 廉洁党建建设管理

parent 06f0d7dd
...@@ -9,6 +9,7 @@ const ROUTE_PATHS = { ...@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login: '/login', login: '/login',
resourceStat: '/resource/stat', resourceStat: '/resource/stat',
resourceBooking: '/resource/booking', resourceBooking: '/resource/booking',
integrityParty: '/integrity-party',
} as const; } as const;
export default defineConfig({ export default defineConfig({
...@@ -47,6 +48,10 @@ export default defineConfig({ ...@@ -47,6 +48,10 @@ export default defineConfig({
path: ROUTE_PATHS.resourceBooking, path: ROUTE_PATHS.resourceBooking,
component: './project_manager_xy/resource-booking/index', component: './project_manager_xy/resource-booking/index',
}, },
{
path: ROUTE_PATHS.integrityParty,
component: './project_manager_xy/integrity-party/index',
},
], ],
}, },
], ],
......
...@@ -3,6 +3,7 @@ import { UserContext } from '@/wrapper/Auth'; ...@@ -3,6 +3,7 @@ import { UserContext } from '@/wrapper/Auth';
import { import {
ArchiveBoxIcon, ArchiveBoxIcon,
Bars3Icon, Bars3Icon,
ClipboardDocumentListIcon,
Squares2X2Icon, Squares2X2Icon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
...@@ -26,6 +27,11 @@ const menuGroups = [ ...@@ -26,6 +27,11 @@ const menuGroups = [
path: '/resource/booking', path: '/resource/booking',
icon: ArchiveBoxIcon, icon: ArchiveBoxIcon,
}, },
{
name: '廉洁党建建设管理',
path: '/integrity-party',
icon: ClipboardDocumentListIcon,
},
], ],
}, },
]; ];
......
# 廉洁党建建设管理流程文档
## 1. 模块目标
本模块用于维护廉洁党建建设相关台账,覆盖培训、谈话、调研、专题会议、廉洁主题活动、节前廉洁提醒 6 类记录。
核心原则:
- 6 类台账分别使用独立 Subject
- 每条台账对应一条 `Record`
- 所有业务字段均写入 `metadata`
- 附件字段均按多附件数组保存,由系统内上传控件维护
- Excel 导入不处理附件,导出时输出已上传附件的文件名和 URL
## 2. 功能入口
- 左侧菜单:`驾驶舱`
- 页面内可切换 `案例库统计 / 廉洁党建统计`
- 廉洁党建统计展示台账总数、本年度记录、附件完整率、最近 30 天更新、台账分类分布、年度分布和最近更新台账
- 左侧菜单:`廉洁党建建设管理`
- 页面内通过 Tab 切换 `培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒`
- 每个 Tab 对应独立台账对象和独立 Excel 模板
## 3. 核心业务对象
| 台账分类 | Subject |
| --- | --- |
| 培训类 | `integrity_party_training` |
| 谈话类 | `integrity_party_talk` |
| 调研类 | `integrity_party_research` |
| 专题会议类 | `integrity_party_meeting` |
| 廉洁主题活动 | `integrity_party_activity` |
| 节前廉洁提醒 | `integrity_party_holiday_reminder` |
## 4. 业务流程
### 4.1 浏览与检索台账
1. 用户进入 `廉洁党建建设管理`
2. 通过顶部 Tab 选择台账分类
3. 页面展示该分类的表格台账
4. 筛选区统一包含关键词、年份和当前分类关键字段
5. 用户点击 `查询` 后,前端调用 `POST /record/query`
6. 列表按 `updated_at`、`created_at` 倒序展示
7. 表格仅展示核心字段、附件状态、更新时间和操作入口
8. 完整字段在编辑弹窗中维护
### 4.2 手动新建台账
1. 用户点击 `新建记录`
2. 前端根据当前 Tab 字段配置生成表单
3. 用户填写最小必填字段
4. 附件字段通过系统上传控件上传,一个字段可保存多个附件
5. 前端自动生成 `title`
6. 前端调用 `POST /record`
7. 保存成功后提示并刷新当前列表
最小必填字段:
- 培训类:培训类别、时间、授课主题
- 谈话类:年份、姓名
- 调研类:调研时间、调研单位
- 专题会议类:会议时间、会议议题
- 廉洁主题活动:开展年份、活动主题
- 节前廉洁提醒:年份、重大节假日
### 4.3 编辑台账
1. 用户在表格中点击 `编辑`
2. 页面打开详情弹窗,展示当前分类全部字段
3. 用户修改字段或上传、移除附件
4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新当前列表
### 4.4 删除台账
1. 用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除
2. 页面弹出二次确认
3. 前端调用 `DELETE /record/:name`
4. 删除后刷新当前列表
5. 删除默认走软删除
### 4.5 Excel 导入
1. 用户在当前 Tab 点击 `下载模板`
2. 前端导出当前分类的独立模板
3. 用户维护 Excel 后点击 `Excel 导入`
4. 前端读取首个工作表并按当前 Tab 字段映射解析
5. 附件字段不参与导入
6. 前端逐条调用 `POST /record`
7. 当前阶段不做重复判断、不自动覆盖,导入全部新增
8. 导入完成后提示成功条数和失败条数
### 4.6 Excel 导出
1. 用户点击 `Excel 导出`
2. 前端按当前筛选条件分页读取全部记录
3. 导出当前分类全部字段
4. 附件字段导出为文件名和 URL,多附件用换行分隔
5. 用户也可以先勾选记录,再点击 `导出勾选`
### 4.7 批量修改
1. 用户勾选一条或多条记录
2. 点击 `批量修改`
3. 弹窗只展示当前分类允许批量覆盖的简单字段
4. 未填写字段保持原值不变
5. 前端逐条调用 `PUT /record/:name`
6. 附件、长文本主要内容等字段一期不参与批量修改
## 5. 字段与数据规则
- 日期字段页面和 Excel 统一展示为 `YYYY-MM-DD`
- 保存时日期字段转换为 ISO 8601 字符串
- 布尔字段使用三态 UI:是、否、未填写,对应 `true / false / null`
- 培训类别固定为:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训
- 重大节假日固定为:元旦、春节、清明、五一、端午、中秋、国庆、其他
- 选择重大节假日 `其他` 时,可填写 `holiday_other`
- 人员、部门、岗位、单位等字段一期全部使用普通文本输入,不做系统引用
## 6. 异常处理与限制
- 保存失败时保留当前表单输入并提示用户
- Excel 解析失败时不发起创建请求
- Excel 批量导入单条失败不影响其它条目继续导入
- 批量删除和单条删除均需要二次确认
- 当前阶段不做导入重复校验,后续可根据 Subject 主键配置扩展覆盖或拦截逻辑
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CheckIcon,
DocumentArrowDownIcon,
DocumentArrowUpIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../../catalyst-ui-kit/javascript/table';
import { Textarea } from '../../../catalyst-ui-kit/javascript/textarea';
import EmptyStatePage from '../EmptyStatePage';
import {
DEFAULT_INTEGRITY_PAGE_SIZE,
INTEGRITY_LEDGER_CONFIG_MAP,
INTEGRITY_LEDGER_CONFIGS,
INTEGRITY_PAGE_SIZE_OPTIONS,
createEmptyLedgerForm,
createLedgerRecord,
deleteLedgerRecord,
deleteLedgerRecords,
exportLedgerTemplateWorkbook,
exportLedgerWorkbook,
exportSelectedLedgerWorkbook,
formatDateTime,
importLedgerWorkbook,
loadLedgerPage,
previewLedgerWorkbook,
updateLedgerRecord,
updateLedgerRecords,
uploadLedgerAttachment,
} from '../integrityPartyService';
function Panel({ className, children }) {
return (
<section
className={clsx(
'rounded-[24px] border border-white/70 bg-white/92 shadow-[0_18px_60px_rgba(15,23,42,0.07)] backdrop-blur',
className,
)}
>
{children}
</section>
);
}
function FieldLabel({ field }) {
return (
<label className="mb-2 block text-sm font-medium text-zinc-700">
{field.label}
{field.required ? <span className="ml-1 text-red-600">*</span> : null}
</label>
);
}
function formatFileSize(size = 0) {
const numericSize = Number(size || 0);
if (!numericSize) {
return '';
}
if (numericSize < 1024) {
return `${numericSize} B`;
}
if (numericSize < 1024 * 1024) {
return `${(numericSize / 1024).toFixed(1)} KB`;
}
return `${(numericSize / 1024 / 1024).toFixed(1)} MB`;
}
function formatCellValue(field, value) {
if (field?.type === 'boolean') {
if (value === true || value === 'true') {
return '是';
}
if (value === false || value === 'false') {
return '否';
}
return '-';
}
if (field?.type === 'file') {
return Array.isArray(value) && value.length > 0 ? `${value.length} 个附件` : '-';
}
if (field?.type === 'long_text') {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > 42 ? `${text.slice(0, 42)}...` : text || '-';
}
return value === null || value === undefined || value === '' ? '-' : String(value);
}
function AttachmentField({ field, value = [], onChange, disabled }) {
const inputRef = useRef(null);
const [uploading, setUploading] = useState(false);
const files = Array.isArray(value) ? value : [];
async function handleUpload(event) {
const selectedFiles = Array.from(event.target.files || []);
if (selectedFiles.length === 0) {
return;
}
setUploading(true);
try {
const uploadedFiles = [];
for (const file of selectedFiles) {
uploadedFiles.push(await uploadLedgerAttachment(file));
}
onChange([...files, ...uploadedFiles]);
} finally {
setUploading(false);
event.target.value = '';
}
}
return (
<div>
<div className="flex flex-wrap items-center gap-3">
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleUpload}
disabled={disabled || uploading}
/>
<Button
type="button"
outline
onClick={() => inputRef.current?.click()}
disabled={disabled || uploading}
>
<DocumentArrowUpIcon data-slot="icon" />
{uploading ? '上传中...' : `上传${field.label}`}
</Button>
<span className="text-sm text-zinc-500">已上传 {files.length} 个</span>
</div>
{files.length > 0 ? (
<div className="mt-3 space-y-2">
{files.map((file, index) => (
<div
key={`${file.name || file.url}-${index}`}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
{file.url ? (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="truncate font-medium text-[var(--pm-brand)] hover:underline"
>
{file.name || file.url}
</a>
) : (
<div className="truncate font-medium text-zinc-800">
{file.name || '未命名附件'}
</div>
)}
<div className="mt-0.5 text-xs text-zinc-500">
{[file.type, formatFileSize(file.size)].filter(Boolean).join(' · ') || '系统附件'}
</div>
</div>
<Button
type="button"
plain
onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
disabled={disabled}
>
<TrashIcon data-slot="icon" />
移除
</Button>
</div>
))}
</div>
) : null}
</div>
);
}
function FieldControl({ field, value, onChange, compact = false }) {
const commonProps = {
value: value ?? '',
onChange: (event) => onChange(event.target.value),
};
if (field.type === 'select') {
return (
<Select {...commonProps}>
<option value="">请选择</option>
{(field.options || []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Select>
);
}
if (field.type === 'boolean') {
return (
<Select {...commonProps}>
<option value="">未填写</option>
<option value="true">是</option>
<option value="false">否</option>
</Select>
);
}
if (field.type === 'long_text') {
return (
<Textarea
{...commonProps}
rows={compact ? 3 : 5}
placeholder={`请输入${field.label}`}
/>
);
}
if (field.type === 'file') {
return (
<AttachmentField
field={field}
value={Array.isArray(value) ? value : []}
onChange={onChange}
/>
);
}
return (
<Input
{...commonProps}
type={
field.type === 'number'
? 'number'
: field.type === 'date'
? 'date'
: 'text'
}
placeholder={`请输入${field.label}`}
/>
);
}
function LedgerForm({ config, form, setForm, fields = null, compact = false }) {
const visibleFields = fields || config.fields;
return (
<div className={clsx('grid gap-5', compact ? 'md:grid-cols-2' : 'md:grid-cols-2')}>
{visibleFields.map((field) => (
<div
key={field.name}
className={clsx(
field.type === 'long_text' || field.type === 'file'
? 'md:col-span-2'
: '',
)}
>
<FieldLabel field={field} />
<FieldControl
field={field}
value={form[field.name]}
compact={compact}
onChange={(nextValue) =>
setForm((current) => ({
...current,
[field.name]: nextValue,
}))
}
/>
</div>
))}
</div>
);
}
function createInitialFilters() {
return INTEGRITY_LEDGER_CONFIGS.reduce((accumulator, config) => {
accumulator[config.key] = {
keyword: '',
year: '',
focusValue: '',
};
return accumulator;
}, {});
}
export default function IntegrityPartyPage() {
const [activeKey, setActiveKey] = useState(INTEGRITY_LEDGER_CONFIGS[0].key);
const activeConfig = INTEGRITY_LEDGER_CONFIG_MAP[activeKey];
const [draftFilters, setDraftFilters] = useState(() => createInitialFilters());
const [appliedFilters, setAppliedFilters] = useState(() => createInitialFilters());
const [records, setRecords] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_INTEGRITY_PAGE_SIZE);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [selectedIds, setSelectedIds] = useState([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState('create');
const [activeRecord, setActiveRecord] = useState(null);
const [detailForm, setDetailForm] = useState(() => createEmptyLedgerForm(activeKey));
const [saving, setSaving] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importPreview, setImportPreview] = useState(null);
const [importing, setImporting] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [batchForm, setBatchForm] = useState(() => createEmptyLedgerForm(activeKey));
const [batchSaving, setBatchSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState({ open: false, mode: 'single' });
const fieldMap = useMemo(() => {
return activeConfig.fields.reduce((accumulator, field) => {
accumulator[field.name] = field;
return accumulator;
}, {});
}, [activeConfig]);
const selectedRecords = useMemo(
() => records.filter((record) => selectedIds.includes(record.id)),
[records, selectedIds],
);
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
const pageStart = total === 0 ? 0 : page * pageSize + 1;
const pageEnd = Math.min((page + 1) * pageSize, total);
async function reload(nextPage = page) {
setLoading(true);
setError('');
const result = await loadLedgerPage(activeKey, {
...appliedFilters[activeKey],
page: nextPage,
pageSize,
});
setRecords(result.records || []);
setTotal(result.total || 0);
setPage(result.page || 0);
setSelectedIds([]);
if (result.error) {
setError(result.error);
}
setLoading(false);
}
useEffect(() => {
reload(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKey, pageSize, appliedFilters]);
function updateDraftFilter(name, value) {
setDraftFilters((current) => ({
...current,
[activeKey]: {
...current[activeKey],
[name]: value,
},
}));
}
function applyFilters() {
setAppliedFilters((current) => ({
...current,
[activeKey]: {
...draftFilters[activeKey],
},
}));
setPage(0);
}
function resetFilters() {
const empty = { keyword: '', year: '', focusValue: '' };
setDraftFilters((current) => ({
...current,
[activeKey]: empty,
}));
setAppliedFilters((current) => ({
...current,
[activeKey]: empty,
}));
setPage(0);
}
function openCreateDialog() {
setDetailMode('create');
setActiveRecord(null);
setDetailForm(createEmptyLedgerForm(activeKey));
setDetailOpen(true);
}
function openEditDialog(record) {
setDetailMode('edit');
setActiveRecord(record);
setDetailForm({
...createEmptyLedgerForm(activeKey),
...record.metadata,
});
setDetailOpen(true);
}
async function saveDetail() {
setSaving(true);
setError('');
try {
if (detailMode === 'create') {
const created = await createLedgerRecord(activeKey, detailForm);
setNotice(`记录“${created.title}”已创建。`);
} else {
const updated = await updateLedgerRecord(activeKey, activeRecord, detailForm);
setNotice(`记录“${updated.title}”已保存。`);
}
setDetailOpen(false);
await reload(page);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
} finally {
setSaving(false);
}
}
async function confirmDelete() {
setSaving(true);
setError('');
try {
if (deleteConfirm.mode === 'batch') {
const result = await deleteLedgerRecords(selectedRecords);
setNotice(
result.failedCount
? `已删除 ${result.deletedCount} 条,${result.failedCount} 条删除失败。`
: `已批量删除 ${result.deletedCount} 条记录。`,
);
} else if (activeRecord) {
await deleteLedgerRecord(activeRecord);
setNotice(`记录“${activeRecord.title}”已删除。`);
setDetailOpen(false);
}
setDeleteConfirm({ open: false, mode: 'single' });
await reload(0);
} catch (deleteError) {
setError(deleteError?.message || '删除失败,请稍后重试。');
} finally {
setSaving(false);
}
}
async function handleTemplateExport() {
try {
const result = await exportLedgerTemplateWorkbook(activeKey);
setNotice(`导入模板已导出:${result.fileName}`);
} catch (exportError) {
setError(exportError?.message || '模板导出失败,请稍后重试。');
}
}
async function handleWorkbookExport() {
try {
const result = await exportLedgerWorkbook(activeKey, appliedFilters[activeKey]);
setNotice(`已导出 ${result.count} 条记录到 ${result.fileName}。`);
} catch (exportError) {
setError(exportError?.message || '导出失败,请稍后重试。');
}
}
async function handleSelectedExport() {
if (selectedRecords.length === 0) {
setError('请先勾选需要导出的记录。');
return;
}
try {
const result = await exportSelectedLedgerWorkbook(activeKey, selectedRecords);
setNotice(`已导出 ${result.count} 条勾选记录到 ${result.fileName}。`);
} catch (exportError) {
setError(exportError?.message || '勾选导出失败,请稍后重试。');
}
}
async function handlePreviewImport(file) {
setImportFile(file);
setImportPreview(null);
if (!file) {
return;
}
try {
const preview = await previewLedgerWorkbook(activeKey, file);
setImportPreview(preview);
} catch (previewError) {
setError(previewError?.message || 'Excel 预览失败,请检查文件。');
}
}
async function handleImportWorkbook() {
if (!importFile) {
setError('请先选择 Excel 文件。');
return;
}
setImporting(true);
try {
const result = await importLedgerWorkbook(activeKey, importFile);
setNotice(
result.failedCount
? `已导入 ${result.createdCount} 条,${result.failedCount} 条失败。`
: `已从 ${result.sheetName} 导入 ${result.createdCount} 条记录。`,
);
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
await reload(0);
} catch (importError) {
setError(importError?.message || '导入失败,请稍后重试。');
} finally {
setImporting(false);
}
}
function openBatchDialog() {
if (selectedRecords.length === 0) {
setError('请先勾选需要批量修改的记录。');
return;
}
setBatchForm(createEmptyLedgerForm(activeKey));
setBatchOpen(true);
}
async function handleBatchSave() {
setBatchSaving(true);
try {
const result = await updateLedgerRecords(activeKey, selectedRecords, batchForm);
setNotice(
result.failedCount
? `已批量更新 ${result.updatedCount} 条,${result.failedCount} 条失败。`
: `已批量更新 ${result.updatedCount} 条记录。`,
);
setBatchOpen(false);
await reload(page);
} catch (batchError) {
setError(batchError?.message || '批量修改失败,请稍后重试。');
} finally {
setBatchSaving(false);
}
}
const focusFilter = activeConfig.focusFilter;
const focusField =
focusFilter?.type === 'select'
? {
label: focusFilter.label,
name: focusFilter.name,
type: 'select',
options: focusFilter.options || [],
}
: null;
return (
<div className="space-y-5">
<Panel className="p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="text-sm font-medium text-[var(--pm-brand)]">
廉洁党建建设管理
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-950 sm:text-3xl">
台账管理
</h1>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" outline onClick={handleTemplateExport}>
<DocumentArrowDownIcon data-slot="icon" />
下载模板
</Button>
<Button
type="button"
outline
onClick={() => {
setImportOpen(true);
setImportFile(null);
setImportPreview(null);
}}
>
<DocumentArrowUpIcon data-slot="icon" />
Excel 导入
</Button>
<Button type="button" outline onClick={handleWorkbookExport}>
<ArrowDownTrayIcon data-slot="icon" />
Excel 导出
</Button>
<Button type="button" color="blue" onClick={openCreateDialog}>
<PlusIcon data-slot="icon" />
新建记录
</Button>
</div>
</div>
<div className="mt-6 flex gap-2 overflow-x-auto pb-1">
{INTEGRITY_LEDGER_CONFIGS.map((config) => (
<button
key={config.key}
type="button"
onClick={() => {
setActiveKey(config.key);
setPage(0);
setSelectedIds([]);
}}
className={clsx(
'shrink-0 rounded-lg px-4 py-2 text-sm font-medium transition',
activeKey === config.key
? 'bg-[var(--pm-brand)] text-white shadow-sm'
: 'bg-white text-zinc-600 ring-1 ring-zinc-200 hover:bg-zinc-50 hover:text-zinc-950',
)}
>
{config.tabLabel}
</button>
))}
</div>
</Panel>
{notice ? (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{notice}
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
) : null}
<Panel className="p-5">
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.55fr_0.85fr_auto_auto] lg:items-end">
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键词</div>
<Input
value={draftFilters[activeKey]?.keyword || ''}
onChange={(event) => updateDraftFilter('keyword', event.target.value)}
placeholder="搜索标题、主要内容、部门、人员等"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">年份</div>
<Input
type="number"
value={draftFilters[activeKey]?.year || ''}
onChange={(event) => updateDraftFilter('year', event.target.value)}
placeholder="YYYY"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">
{focusFilter.label}
</div>
{focusField ? (
<FieldControl
field={focusField}
value={draftFilters[activeKey]?.focusValue || ''}
onChange={(value) => updateDraftFilter('focusValue', value)}
/>
) : (
<Input
value={draftFilters[activeKey]?.focusValue || ''}
onChange={(event) => updateDraftFilter('focusValue', event.target.value)}
placeholder={`输入${focusFilter.label}`}
/>
)}
</div>
<Button type="button" color="blue" onClick={applyFilters}>
<CheckIcon data-slot="icon" />
查询
</Button>
<Button type="button" outline onClick={resetFilters}>
<ArrowPathIcon data-slot="icon" />
重置
</Button>
</div>
</Panel>
<Panel className="overflow-hidden">
<div className="flex flex-col gap-3 border-b border-zinc-200 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="font-semibold text-zinc-950">{activeConfig.label}</div>
<div className="mt-1 text-sm text-zinc-500">
共 {total} 条,当前显示 {pageStart}-{pageEnd} 条
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" outline onClick={handleSelectedExport}>
<ArrowDownTrayIcon data-slot="icon" />
导出勾选
</Button>
<Button type="button" outline onClick={openBatchDialog}>
<PencilSquareIcon data-slot="icon" />
批量修改
</Button>
<Button
type="button"
outline
onClick={() => {
if (selectedRecords.length === 0) {
setError('请先勾选需要批量删除的记录。');
return;
}
setDeleteConfirm({ open: true, mode: 'batch' });
}}
>
<TrashIcon data-slot="icon" />
批量删除
</Button>
</div>
</div>
{loading ? (
<div className="px-5 py-8 text-sm text-[var(--pm-brand)]">
正在加载台账...
</div>
) : records.length === 0 ? (
<EmptyStatePage title="当前筛选条件下还没有台账记录" />
) : (
<Table dense striped className="[--gutter:--spacing(5)]">
<TableHead>
<TableRow>
<TableHeader className="w-12">
<input
type="checkbox"
aria-label="全选当前页"
checked={
records.length > 0 &&
records.every((record) => selectedIds.includes(record.id))
}
onChange={(event) =>
setSelectedIds(
event.target.checked ? records.map((record) => record.id) : [],
)
}
className="size-4 rounded border-zinc-300 text-[var(--pm-brand)]"
/>
</TableHeader>
{activeConfig.tableColumns.map((fieldName) => (
<TableHeader key={fieldName}>
{fieldMap[fieldName]?.label || fieldName}
</TableHeader>
))}
<TableHeader>附件状态</TableHeader>
<TableHeader>更新时间</TableHeader>
<TableHeader className="text-right">操作</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{records.map((record) => (
<TableRow key={record.id}>
<TableCell>
<input
type="checkbox"
aria-label={`勾选记录 ${record.title}`}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) =>
event.target.checked
? [...new Set([...current, record.id])]
: current.filter((id) => id !== record.id),
);
}}
className="size-4 rounded border-zinc-300 text-[var(--pm-brand)]"
/>
</TableCell>
{activeConfig.tableColumns.map((fieldName) => (
<TableCell key={fieldName} className="max-w-[260px]">
<span title={formatCellValue(fieldMap[fieldName], record.metadata[fieldName])}>
{formatCellValue(fieldMap[fieldName], record.metadata[fieldName])}
</span>
</TableCell>
))}
<TableCell>
<span
className={clsx(
'inline-flex rounded-full px-2.5 py-1 text-xs font-medium',
record.attachmentCount > 0
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
: 'bg-zinc-100 text-zinc-600 ring-1 ring-zinc-200',
)}
>
{record.attachmentStatus}
</span>
</TableCell>
<TableCell>{formatDateTime(record.updatedAt)}</TableCell>
<TableCell className="text-right">
<Button type="button" plain onClick={() => openEditDialog(record)}>
<PencilSquareIcon data-slot="icon" />
编辑
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex flex-col gap-3 border-t border-zinc-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-zinc-500">已勾选 {selectedRecords.length} 条</div>
<div className="flex flex-wrap items-center gap-2">
<Select
aria-label="选择每页数量"
value={String(pageSize)}
onChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
className="w-28"
>
{INTEGRITY_PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option} 条/页
</option>
))}
</Select>
<Button
type="button"
outline
disabled={page <= 0}
onClick={() => reload(Math.max(page - 1, 0))}
>
上一页
</Button>
<span className="px-2 text-sm text-zinc-500">
{page + 1} / {totalPages}
</span>
<Button
type="button"
outline
disabled={page + 1 >= totalPages}
onClick={() => reload(page + 1)}
>
下一页
</Button>
</div>
</div>
</Panel>
<Dialog open={detailOpen} onClose={setDetailOpen} size="5xl">
<DialogTitle>
{detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : '编辑台账记录'}
</DialogTitle>
<DialogDescription>
业务字段统一保存到 Record 的 metadata 中,附件为系统内上传材料。
</DialogDescription>
<DialogBody>
<LedgerForm config={activeConfig} form={detailForm} setForm={setDetailForm} />
</DialogBody>
<DialogActions>
{detailMode === 'edit' ? (
<Button
type="button"
outline
onClick={() => setDeleteConfirm({ open: true, mode: 'single' })}
>
<TrashIcon data-slot="icon" />
删除
</Button>
) : null}
<Button type="button" plain onClick={() => setDetailOpen(false)}>
取消
</Button>
<Button type="button" color="blue" onClick={saveDetail} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={setImportOpen} size="4xl">
<DialogTitle>Excel 导入{activeConfig.tabLabel}</DialogTitle>
<DialogDescription>
当前导入只处理台账字段,附件请在系统内上传维护。
</DialogDescription>
<DialogBody>
<div className="rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 p-5">
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={(event) => handlePreviewImport(event.target.files?.[0] || null)}
className="block w-full text-sm text-zinc-600 file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--pm-brand)] file:px-4 file:py-2 file:text-sm file:font-medium file:text-white"
/>
</div>
{importPreview ? (
<div className="mt-5">
<div className="mb-3 text-sm text-zinc-600">
工作表:{importPreview.sheetName},识别到 {importPreview.totalRows} 行
</div>
<div className="overflow-x-auto rounded-2xl border border-zinc-200">
<table className="min-w-full text-left text-sm">
<thead className="bg-zinc-50 text-zinc-500">
<tr>
<th className="px-4 py-3">行号</th>
{activeConfig.fields
.filter((field) => field.type !== 'file')
.slice(0, 6)
.map((field) => (
<th key={field.name} className="px-4 py-3">
{field.label}
</th>
))}
</tr>
</thead>
<tbody>
{importPreview.previewRows.map((row) => (
<tr key={row.rowNumber} className="border-t border-zinc-100">
<td className="px-4 py-3 text-zinc-500">{row.rowNumber}</td>
{activeConfig.fields
.filter((field) => field.type !== 'file')
.slice(0, 6)
.map((field) => (
<td key={field.name} className="px-4 py-3">
{row.metadata[field.name] || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</DialogBody>
<DialogActions>
<Button type="button" outline onClick={handleTemplateExport}>
下载模板
</Button>
<Button type="button" plain onClick={() => setImportOpen(false)}>
取消
</Button>
<Button
type="button"
color="blue"
onClick={handleImportWorkbook}
disabled={importing || !importFile}
>
{importing ? '导入中...' : '确认导入'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={batchOpen} onClose={setBatchOpen} size="3xl">
<DialogTitle>批量修改{activeConfig.tabLabel}</DialogTitle>
<DialogDescription>
当前已勾选 {selectedRecords.length} 条记录。仅覆盖你填写的字段,留空字段保持原值。
</DialogDescription>
<DialogBody>
<LedgerForm
config={activeConfig}
form={batchForm}
setForm={setBatchForm}
fields={activeConfig.batchFields.map((fieldName) => fieldMap[fieldName]).filter(Boolean)}
compact
/>
</DialogBody>
<DialogActions>
<Button type="button" plain onClick={() => setBatchOpen(false)}>
取消
</Button>
<Button
type="button"
color="blue"
onClick={handleBatchSave}
disabled={batchSaving}
>
{batchSaving ? '更新中...' : '批量保存'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={deleteConfirm.open}
onClose={(open) => setDeleteConfirm((current) => ({ ...current, open }))}
size="md"
>
<DialogTitle>
{deleteConfirm.mode === 'batch' ? '确认批量删除' : '确认删除记录'}
</DialogTitle>
<DialogDescription>
{deleteConfirm.mode === 'batch'
? `即将删除 ${selectedRecords.length} 条勾选记录,请确认是否继续。`
: activeRecord
? `即将删除:${activeRecord.title}`
: '未找到当前记录。'}
</DialogDescription>
<DialogActions>
<Button
type="button"
plain
onClick={() => setDeleteConfirm({ open: false, mode: 'single' })}
>
取消
</Button>
<Button type="button" color="red" onClick={confirmDelete} disabled={saving}>
确认删除
</Button>
</DialogActions>
</Dialog>
</div>
);
}
import {
countRecords,
createRecord,
queryRecords,
removeRecord,
updateRecord,
uploadFile,
} from '@/services/DataService';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
export const INTEGRITY_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_INTEGRITY_PAGE_SIZE = 20;
export const TRAINING_CATEGORY_OPTIONS = [
'廉政党课讲授',
'廉洁合规培训',
'专题廉洁合规教育培训',
'新员工廉洁教育培训',
];
export const HOLIDAY_OPTIONS = [
'元旦',
'春节',
'清明',
'五一',
'端午',
'中秋',
'国庆',
'其他',
];
const FIELD_TYPES = {
TEXT: 'text',
LONG_TEXT: 'long_text',
NUMBER: 'number',
DATE: 'date',
SELECT: 'select',
BOOLEAN: 'boolean',
FILE: 'file',
};
export const INTEGRITY_LEDGER_CONFIGS = [
{
key: 'training',
subject: 'integrity_party_training',
label: '廉洁党建培训记录',
tabLabel: '培训类',
filePrefix: '廉洁党建培训记录',
yearField: 'training_date',
titleFallback: '未命名培训记录',
buildTitle: (metadata) =>
compactTitle([
metadata.training_category,
metadata.lecture_topic,
formatDate(metadata.training_date),
]) || '未命名培训记录',
focusFilter: {
name: 'training_category',
label: '培训类别',
type: FIELD_TYPES.SELECT,
options: TRAINING_CATEGORY_OPTIONS,
},
tableColumns: [
'sequence_no',
'training_category',
'training_date',
'lecture_topic',
'training_target',
'lead_department',
],
batchFields: ['training_category', 'training_date', 'lead_department'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('培训类别', 'training_category', FIELD_TYPES.SELECT, {
required: true,
options: TRAINING_CATEGORY_OPTIONS,
}),
field('时间', 'training_date', FIELD_TYPES.DATE, { required: true }),
field('培训对象', 'training_target', FIELD_TYPES.TEXT),
field('培训形式', 'training_format', FIELD_TYPES.TEXT),
field('牵头部门', 'lead_department', FIELD_TYPES.TEXT),
field('授课人', 'lecturer', FIELD_TYPES.TEXT),
field('授课主题', 'lecture_topic', FIELD_TYPES.TEXT, { required: true }),
field('主要内容', 'main_content', FIELD_TYPES.LONG_TEXT),
field('参加人数', 'participant_count', FIELD_TYPES.NUMBER),
field('课件材料', 'courseware_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('签到表', 'sign_in_sheets', FIELD_TYPES.FILE, { multiple: true }),
],
},
{
key: 'talk',
subject: 'integrity_party_talk',
label: '廉洁党建谈话记录',
tabLabel: '谈话类',
filePrefix: '廉洁党建谈话记录',
yearField: 'year',
titleFallback: '未命名谈话记录',
buildTitle: (metadata) =>
compactTitle([
metadata.person_name,
metadata.year ? `${metadata.year}年` : '',
formatDate(metadata.planned_talk_date),
]) || '未命名谈话记录',
focusFilter: {
name: 'person_or_department',
label: '姓名 / 部门',
matchFields: ['person_name', 'primary_department'],
},
tableColumns: [
'sequence_no',
'year',
'person_name',
'primary_department',
'position_name',
'planned_talk_date',
'actual_talk_date',
],
batchFields: ['year', 'primary_department', 'position_name'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('年份', 'year', FIELD_TYPES.NUMBER, { required: true }),
field('姓名', 'person_name', FIELD_TYPES.TEXT, { required: true }),
field('所属一级部门', 'primary_department', FIELD_TYPES.TEXT),
field('岗位名称', 'position_name', FIELD_TYPES.TEXT),
field('岗位晋升时间', 'promotion_date', FIELD_TYPES.DATE),
field('是否党员', 'is_party_member', FIELD_TYPES.BOOLEAN),
field('是否领导干部', 'is_leader', FIELD_TYPES.BOOLEAN),
field('是否投资序列', 'is_investment_sequence', FIELD_TYPES.BOOLEAN),
field('最近一期谈话时间', 'latest_talk_date', FIELD_TYPES.DATE),
field('本年度计划谈话时间', 'planned_talk_date', FIELD_TYPES.DATE),
field('是否应做任前谈话', 'should_pre_appointment_talk', FIELD_TYPES.BOOLEAN),
field('是否已做任前谈话', 'has_pre_appointment_talk', FIELD_TYPES.BOOLEAN),
field(
'是否纪委书记主动约谈',
'is_discipline_secretary_interview',
FIELD_TYPES.BOOLEAN,
),
field('本年度实际谈话时间', 'actual_talk_date', FIELD_TYPES.DATE),
field('谈话支持材料', 'talk_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
],
},
{
key: 'research',
subject: 'integrity_party_research',
label: '廉洁党建调研记录',
tabLabel: '调研类',
filePrefix: '廉洁党建调研记录',
yearField: 'research_date',
titleFallback: '未命名调研记录',
buildTitle: (metadata) =>
compactTitle([metadata.research_unit, formatDate(metadata.research_date)]) ||
'未命名调研记录',
focusFilter: {
name: 'research_unit',
label: '调研单位',
matchFields: ['research_unit'],
},
tableColumns: [
'sequence_no',
'research_date',
'research_unit',
'research_staff_and_position',
'research_content',
],
batchFields: ['research_date', 'research_unit', 'remarks'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('调研时间', 'research_date', FIELD_TYPES.DATE, { required: true }),
field('调研人员及职务', 'research_staff_and_position', FIELD_TYPES.TEXT),
field('调研单位', 'research_unit', FIELD_TYPES.TEXT, { required: true }),
field('调研内容', 'research_content', FIELD_TYPES.LONG_TEXT),
field('调研方案或调研报告', 'research_report_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('备注', 'remarks', FIELD_TYPES.LONG_TEXT),
],
},
{
key: 'meeting',
subject: 'integrity_party_meeting',
label: '廉洁党建专题会议记录',
tabLabel: '专题会议类',
filePrefix: '廉洁党建专题会议记录',
yearField: 'meeting_date',
titleFallback: '未命名会议记录',
buildTitle: (metadata) =>
compactTitle([metadata.meeting_topic, formatDate(metadata.meeting_date)]) ||
'未命名会议记录',
focusFilter: {
name: 'meeting_topic',
label: '会议议题',
matchFields: ['meeting_topic'],
},
tableColumns: ['sequence_no', 'meeting_date', 'meeting_topic', 'main_content'],
batchFields: ['meeting_date', 'meeting_topic'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('会议时间', 'meeting_date', FIELD_TYPES.DATE, { required: true }),
field('会议议题', 'meeting_topic', FIELD_TYPES.TEXT, { required: true }),
field('会议主要内容', 'main_content', FIELD_TYPES.LONG_TEXT),
field('支持材料', 'supporting_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
],
},
{
key: 'activity',
subject: 'integrity_party_activity',
label: '廉洁主题活动记录',
tabLabel: '廉洁主题活动',
filePrefix: '廉洁主题活动记录',
yearField: 'year',
titleFallback: '未命名活动记录',
buildTitle: (metadata) =>
compactTitle([
metadata.activity_theme,
metadata.year ? `${metadata.year}年` : '',
]) || '未命名活动记录',
focusFilter: {
name: 'activity_theme',
label: '活动主题',
matchFields: ['activity_theme'],
},
tableColumns: ['sequence_no', 'year', 'activity_theme', 'activity_format'],
batchFields: ['year', 'activity_format'],
fields: [
field('序号', 'sequence_no', FIELD_TYPES.NUMBER),
field('开展年份', 'year', FIELD_TYPES.NUMBER, { required: true }),
field('活动主题', 'activity_theme', FIELD_TYPES.TEXT, { required: true }),
field('活动形式', 'activity_format', FIELD_TYPES.TEXT),
field('活动成果', 'activity_result', FIELD_TYPES.LONG_TEXT),
field('支持材料', 'supporting_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
],
},
{
key: 'holiday_reminder',
subject: 'integrity_party_holiday_reminder',
label: '节前廉洁提醒记录',
tabLabel: '节前廉洁提醒',
filePrefix: '节前廉洁提醒记录',
yearField: 'year',
titleFallback: '未命名节前提醒',
buildTitle: (metadata) =>
compactTitle([
metadata.year ? `${metadata.year}年` : '',
metadata.holiday_name === '其他'
? metadata.holiday_other || metadata.holiday_name
: metadata.holiday_name,
]) || '未命名节前提醒',
focusFilter: {
name: 'holiday_name',
label: '重大节假日',
type: FIELD_TYPES.SELECT,
options: HOLIDAY_OPTIONS,
},
tableColumns: ['year', 'holiday_name', 'holiday_other', 'other_measures'],
batchFields: ['year', 'holiday_name', 'holiday_other'],
fields: [
field('年份', 'year', FIELD_TYPES.NUMBER, { required: true }),
field('重大节假日', 'holiday_name', FIELD_TYPES.SELECT, {
required: true,
options: HOLIDAY_OPTIONS,
}),
field('其他节假日说明', 'holiday_other', FIELD_TYPES.TEXT),
field('廉洁谈话提纲上传', 'talk_outline_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('廉洁提醒支持材料上传', 'reminder_materials', FIELD_TYPES.FILE, {
multiple: true,
}),
field('其他活动举措', 'other_measures', FIELD_TYPES.LONG_TEXT),
],
},
];
export const INTEGRITY_LEDGER_CONFIG_MAP = INTEGRITY_LEDGER_CONFIGS.reduce(
(accumulator, config) => {
accumulator[config.key] = config;
return accumulator;
},
{},
);
function field(label, name, type, options = {}) {
return {
label,
name,
type,
required: Boolean(options.required),
multiple: Boolean(options.multiple),
options: options.options || [],
aliases: [label, name, ...(options.aliases || [])],
};
}
function compactTitle(parts = []) {
return parts
.map((item) => String(item || '').trim())
.filter(Boolean)
.join(' · ');
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractArray(response) {
const candidates = [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
return candidate;
}
}
return [];
}
function extractCount(response) {
const candidates = [
response,
response?.data,
response?.result,
response?.total_records,
response?.data?.total_records,
response?.result?.total_records,
response?.count,
response?.data?.count,
response?.data?.total,
response?.total,
response?.result?.count,
response?.result?.total,
];
for (const candidate of candidates) {
const numericCandidate = Number(candidate);
if (Number.isFinite(numericCandidate)) {
return numericCandidate;
}
}
return 0;
}
function extractTotalRecords(response) {
const candidates = [
response?.total_records,
response?.data?.total_records,
response?.result?.total_records,
response?.total,
response?.data?.total,
response?.result?.total,
response?.count,
response?.data?.count,
response?.result?.count,
];
for (const candidate of candidates) {
const numericCandidate = Number(candidate);
if (Number.isFinite(numericCandidate)) {
return numericCandidate;
}
}
return null;
}
function normalizeHeaderKey(value = '') {
return String(value)
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '');
}
function getConfig(configKey) {
const config = INTEGRITY_LEDGER_CONFIG_MAP[configKey];
if (!config) {
throw new Error('未找到对应的廉洁党建台账配置');
}
return config;
}
function getFieldMap(config) {
return config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = fieldConfig;
return accumulator;
}, {});
}
function normalizeBoolean(value) {
if (value === true || value === false || value === null) {
return value;
}
const text = String(value ?? '').trim();
if (!text) {
return null;
}
if (['true', '1', '是', 'Y', 'y', 'yes', 'YES'].includes(text)) {
return true;
}
if (['false', '0', '否', 'N', 'n', 'no', 'NO'].includes(text)) {
return false;
}
return null;
}
function normalizeNumber(value) {
const text = String(value ?? '').trim();
if (!text) {
return null;
}
const numericValue = Number(text);
return Number.isFinite(numericValue) ? numericValue : null;
}
export function formatDate(value) {
if (!value) {
return '';
}
const parsed = dayjs(value);
if (parsed.isValid()) {
return parsed.format('YYYY-MM-DD');
}
const text = String(value).trim();
const matched = text.match(/^(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})/);
if (!matched) {
return text;
}
return [
matched[1],
String(matched[2]).padStart(2, '0'),
String(matched[3]).padStart(2, '0'),
].join('-');
}
function normalizeDateValue(value) {
const dateText = formatDate(value);
if (!dateText) {
return null;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(dateText)) {
return `${dateText}T00:00:00.000Z`;
}
return dateText;
}
function normalizeFileList(value) {
const list = Array.isArray(value) ? value : value ? [value] : [];
return list
.map((item) => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return {
name: item,
type: '',
size: 0,
url: item,
};
}
return {
name: item.name || item.filename || item.title || item.url || '未命名附件',
type: item.type || item.mime_type || '',
size: Number(item.size || 0),
url: item.url || item.path || item.href || item.src || '',
};
})
.filter((item) => item?.name || item?.url);
}
function normalizeFieldValue(fieldConfig, value, { forForm = false } = {}) {
if (fieldConfig.type === FIELD_TYPES.NUMBER) {
return forForm
? value === null || value === undefined
? ''
: String(value)
: normalizeNumber(value);
}
if (fieldConfig.type === FIELD_TYPES.DATE) {
return forForm ? formatDate(value) : normalizeDateValue(value);
}
if (fieldConfig.type === FIELD_TYPES.BOOLEAN) {
if (forForm) {
const booleanValue = normalizeBoolean(value);
if (booleanValue === true) {
return 'true';
}
if (booleanValue === false) {
return 'false';
}
return '';
}
return normalizeBoolean(value);
}
if (fieldConfig.type === FIELD_TYPES.FILE) {
return normalizeFileList(value);
}
return String(value ?? '').trim();
}
function isEmptyMetadataRow(metadata = {}, config) {
return config.fields
.filter((fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE)
.every((fieldConfig) => {
const value = metadata[fieldConfig.name];
return value === '' || value === null || value === undefined;
});
}
export function createEmptyLedgerForm(configKey) {
const config = getConfig(configKey);
return config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] =
fieldConfig.type === FIELD_TYPES.FILE ? [] : '';
return accumulator;
}, {});
}
export function normalizeLedgerRecord(record = {}, configKey) {
const config = getConfig(configKey);
const fieldMap = getFieldMap(config);
const metadata = record?.metadata || {};
const normalizedMetadata = config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
metadata[fieldConfig.name],
{ forForm: true },
);
return accumulator;
}, {});
const attachmentCount = config.fields
.filter((fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE)
.reduce((sum, fieldConfig) => {
return sum + normalizeFileList(metadata[fieldConfig.name]).length;
}, 0);
const requiredAttachmentFields = config.fields.filter(
(fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE,
);
const filledAttachmentFields = requiredAttachmentFields.filter(
(fieldConfig) => normalizeFileList(metadata[fieldConfig.name]).length > 0,
);
const title = record.title || config.buildTitle(normalizedMetadata);
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
title: title || config.titleFallback,
subject: config.subject,
ledgerKey: config.key,
ledgerLabel: config.label,
metadata: normalizedMetadata,
rawMetadata: metadata,
rawRecord: record,
attachmentCount,
attachmentStatus:
requiredAttachmentFields.length === 0
? '无附件字段'
: `${filledAttachmentFields.length}/${requiredAttachmentFields.length}`,
createdAt: record.created_at || '',
updatedAt: record.updated_at || record.created_at || '',
updatedAtLabel: formatDateTime(record.updated_at || record.created_at),
fieldMap,
};
}
export function formatDateTime(value) {
if (!value) {
return '-';
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : String(value);
}
function buildLedgerMetadata(config, values = {}) {
return config.fields.reduce((metadata, fieldConfig) => {
metadata[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
values[fieldConfig.name],
);
return metadata;
}, {});
}
function validateLedgerMetadata(config, metadata = {}) {
const missingFields = config.fields.filter((fieldConfig) => {
if (!fieldConfig.required) {
return false;
}
const value = metadata[fieldConfig.name];
return value === '' || value === null || value === undefined;
});
if (missingFields.length > 0) {
throw new Error(`请填写必填字段:${missingFields.map((item) => item.label).join('、')}`);
}
}
export function buildLedgerPayload(configKey, values = {}) {
const config = getConfig(configKey);
const metadata = buildLedgerMetadata(config, values);
validateLedgerMetadata(config, metadata);
const displayMetadata = config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
metadata[fieldConfig.name],
{ forForm: true },
);
return accumulator;
}, {});
const title = config.buildTitle(displayMetadata);
return {
subject: config.subject,
title,
content_type: 'text',
content: title,
metadata,
};
}
export async function createLedgerRecord(configKey, values = {}) {
const payload = buildLedgerPayload(configKey, values);
const created = await createRecord(payload);
return normalizeLedgerRecord(created?.data || created?.result || payload, configKey);
}
export async function updateLedgerRecord(configKey, ledgerRecord, values = {}) {
const config = getConfig(configKey);
const recordName = ledgerRecord?.recordName || ledgerRecord?.id;
if (!recordName) {
throw new Error('未找到可更新的台账记录标识');
}
const payload = buildLedgerPayload(configKey, values);
const nextMetadata = {
...ledgerRecord.rawMetadata,
...payload.metadata,
};
const updated = await updateRecord(recordName, {
title: payload.title,
content_type: 'text',
content: payload.content,
metadata: nextMetadata,
});
return normalizeLedgerRecord(
updated?.data ||
updated?.result || {
...ledgerRecord.rawRecord,
title: payload.title,
content: payload.content,
metadata: nextMetadata,
},
config.key,
);
}
export async function deleteLedgerRecord(ledgerRecord) {
const recordName = ledgerRecord?.recordName || ledgerRecord?.id;
if (!recordName) {
throw new Error('未找到可删除的台账记录标识');
}
await removeRecord(recordName);
}
function buildKeywordFilter(config, keyword = '') {
const normalizedKeyword = String(keyword || '').trim();
if (!normalizedKeyword) {
return null;
}
const regex = {
$regex: escapeRegExp(normalizedKeyword),
$options: 'i',
};
const textFields = config.fields.filter(
(fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE,
);
return {
$or: [{ title: regex }, ...textFields.map((fieldConfig) => ({
[`metadata.${fieldConfig.name}`]: regex,
}))],
};
}
function buildYearFilter(config, year = '') {
const normalizedYear = String(year || '').trim();
if (!normalizedYear) {
return null;
}
const yearField = config.fields.find(
(fieldConfig) => fieldConfig.name === config.yearField,
);
if (!yearField) {
return null;
}
if (yearField.type === FIELD_TYPES.NUMBER) {
const numericYear = Number(normalizedYear);
return Number.isFinite(numericYear)
? { [`metadata.${config.yearField}`]: numericYear }
: null;
}
return {
[`metadata.${config.yearField}`]: {
$regex: `^${escapeRegExp(normalizedYear)}`,
},
};
}
function buildFocusFilter(config, value = '') {
const normalizedValue = String(value || '').trim();
if (!normalizedValue || !config.focusFilter) {
return null;
}
if (config.focusFilter.type === FIELD_TYPES.SELECT) {
return {
[`metadata.${config.focusFilter.name}`]: normalizedValue,
};
}
const regex = {
$regex: escapeRegExp(normalizedValue),
$options: 'i',
};
return {
$or: (config.focusFilter.matchFields || [config.focusFilter.name]).map(
(fieldName) => ({
[`metadata.${fieldName}`]: regex,
}),
),
};
}
function buildLedgerFilter(config, filters = {}) {
const clauses = [
buildKeywordFilter(config, filters.keyword),
buildYearFilter(config, filters.year),
buildFocusFilter(config, filters.focusValue),
].filter(Boolean);
if (clauses.length === 0) {
return {};
}
if (clauses.length === 1) {
return clauses[0];
}
return {
$and: clauses,
};
}
export async function loadLedgerPage(configKey, filters = {}) {
const config = getConfig(configKey);
const page = Number(filters.page || 0);
const pageSize = Number(filters.pageSize || DEFAULT_INTEGRITY_PAGE_SIZE);
const filter = buildLedgerFilter(config, filters);
try {
const response = await queryRecords({
subject: config.subject,
page,
page_size: pageSize,
filter,
sort: '-updated_at -created_at',
});
const records = extractArray(response).map((record) =>
normalizeLedgerRecord(record, config.key),
);
const total =
extractTotalRecords(response) ??
extractCount(
await countRecords({
subject: config.subject,
filter,
}),
);
return {
records,
total,
page,
pageSize,
hasMore: (page + 1) * pageSize < total,
error: '',
};
} catch (error) {
return {
records: [],
total: 0,
page,
pageSize,
hasMore: false,
error: error?.message || '台账数据加载失败,请稍后重试。',
};
}
}
async function loadAllLedgerRecords(configKey, filters = {}) {
const pageSize = 200;
const mergedRecords = [];
let page = 0;
let hasMore = true;
while (hasMore) {
const result = await loadLedgerPage(configKey, {
...filters,
page,
pageSize,
});
if (result.error) {
throw new Error(result.error);
}
mergedRecords.push(...(result.records || []));
hasMore = Boolean(result.hasMore);
page += 1;
}
return mergedRecords;
}
async function runMutationInBatches(entries = [], worker, batchSize = 5) {
const successes = [];
const failures = [];
for (let index = 0; index < entries.length; index += batchSize) {
const batch = entries.slice(index, index + batchSize);
const results = await Promise.allSettled(batch.map((entry) => worker(entry)));
results.forEach((result, batchIndex) => {
const entry = batch[batchIndex];
if (result.status === 'fulfilled') {
successes.push(result.value ?? entry);
return;
}
failures.push({
id: entry?.id || entry?.recordName || '',
label: entry?.title || entry?.label || '未命名记录',
rowNumber: entry?.rowNumber,
message: result.reason?.message || '操作失败',
});
});
}
return {
successes,
failures,
};
}
export async function deleteLedgerRecords(records = []) {
const { successes, failures } = await runMutationInBatches(
records.filter(Boolean),
(record) => deleteLedgerRecord(record),
);
return {
deletedCount: successes.length,
failedCount: failures.length,
failures,
};
}
export async function updateLedgerRecords(configKey, records = [], values = {}) {
const config = getConfig(configKey);
const fieldMap = getFieldMap(config);
const metadataPatch = {};
config.batchFields.forEach((fieldName) => {
const fieldConfig = fieldMap[fieldName];
const rawValue = values[fieldName];
const hasPatchValue =
fieldConfig?.type === FIELD_TYPES.BOOLEAN
? rawValue === 'true' || rawValue === 'false'
: String(rawValue ?? '').trim() !== '';
if (fieldConfig && hasPatchValue) {
metadataPatch[fieldName] = normalizeFieldValue(fieldConfig, rawValue);
}
});
if (Object.keys(metadataPatch).length === 0) {
throw new Error('请至少填写一个需要批量覆盖的字段');
}
const { successes, failures } = await runMutationInBatches(
records.filter(Boolean),
async (record) => {
const recordName = record?.recordName || record?.id;
if (!recordName) {
throw new Error('未找到可更新的台账记录标识');
}
const nextMetadata = {
...record.rawMetadata,
...metadataPatch,
};
const formMetadata = config.fields.reduce((accumulator, fieldConfig) => {
accumulator[fieldConfig.name] = normalizeFieldValue(
fieldConfig,
nextMetadata[fieldConfig.name],
{ forForm: true },
);
return accumulator;
}, {});
const title = config.buildTitle(formMetadata);
const updated = await updateRecord(recordName, {
title,
content_type: 'text',
content: title,
metadata: nextMetadata,
});
return normalizeLedgerRecord(
updated?.data ||
updated?.result || {
...record.rawRecord,
title,
content: title,
metadata: nextMetadata,
},
config.key,
);
},
);
return {
updatedRecords: successes,
updatedCount: successes.length,
failedCount: failures.length,
failures,
};
}
function getImportFieldValue(row = {}, aliases = []) {
const normalizedAliasMap = new Map(
Object.entries(row).map(([key, value]) => [normalizeHeaderKey(key), value]),
);
for (const alias of aliases) {
const matchedValue = normalizedAliasMap.get(normalizeHeaderKey(alias));
if (matchedValue !== undefined && matchedValue !== null) {
return String(matchedValue).trim();
}
}
return '';
}
function normalizeWorkbookRow(config, row = {}, rowIndex = 0) {
const metadata = {};
config.fields
.filter((fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE)
.forEach((fieldConfig) => {
metadata[fieldConfig.name] = getImportFieldValue(row, fieldConfig.aliases);
});
return {
rowNumber: rowIndex + 2,
metadata,
};
}
export async function previewLedgerWorkbook(configKey, file) {
const config = getConfig(configKey);
if (!file) {
throw new Error('请先选择 Excel 文件');
}
const workbook = XLSX.read(await file.arrayBuffer(), {
type: 'array',
});
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new Error('Excel 中没有可读取的工作表');
}
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils
.sheet_to_json(sheet, {
defval: '',
raw: false,
})
.map((row, index) => normalizeWorkbookRow(config, row, index))
.filter((row) => !isEmptyMetadataRow(row.metadata, config));
return {
sheetName,
rows,
totalRows: rows.length,
previewRows: rows.slice(0, 5),
};
}
export async function importLedgerWorkbook(configKey, file) {
const config = getConfig(configKey);
const preview = await previewLedgerWorkbook(configKey, file);
if (preview.totalRows === 0) {
throw new Error('未识别到可导入的数据行,请检查当前 Tab 对应的模板表头');
}
const entries = preview.rows.map((row) => ({
rowNumber: row.rowNumber,
payload: buildLedgerPayload(config.key, row.metadata),
}));
const { successes, failures } = await runMutationInBatches(entries, async (entry) => {
const created = await createRecord(entry.payload);
return normalizeLedgerRecord(
created?.data || created?.result || entry.payload,
config.key,
);
});
return {
sheetName: preview.sheetName,
totalRows: preview.totalRows,
createdCount: successes.length,
failedCount: failures.length,
failures,
createdRecords: successes,
};
}
function formatBoolean(value) {
const booleanValue = normalizeBoolean(value);
if (booleanValue === true) {
return '是';
}
if (booleanValue === false) {
return '否';
}
return '';
}
function formatFileList(value) {
return normalizeFileList(value)
.map((item) => [item.name, item.url].filter(Boolean).join(' '))
.join('\n');
}
function formatExportValue(fieldConfig, value) {
if (fieldConfig.type === FIELD_TYPES.DATE) {
return formatDate(value);
}
if (fieldConfig.type === FIELD_TYPES.BOOLEAN) {
return formatBoolean(value);
}
if (fieldConfig.type === FIELD_TYPES.FILE) {
return formatFileList(value);
}
return value ?? '';
}
function buildExportRow(config, record = {}) {
const source = record.rawMetadata || record.metadata || {};
return config.fields.reduce((row, fieldConfig) => {
row[fieldConfig.label] = formatExportValue(fieldConfig, source[fieldConfig.name]);
return row;
}, {});
}
function createWorkbookFromRows(config, rows = [], sheetName) {
const headers = config.fields.map((fieldConfig) => fieldConfig.label);
const worksheet = XLSX.utils.json_to_sheet(rows, { header: headers });
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
return workbook;
}
export async function exportLedgerWorkbook(configKey, filters = {}) {
const records = await loadAllLedgerRecords(configKey, filters);
return exportSelectedLedgerWorkbook(configKey, records);
}
export async function exportSelectedLedgerWorkbook(configKey, records = []) {
const config = getConfig(configKey);
const rows = records.map((record) => buildExportRow(config, record));
const workbook = createWorkbookFromRows(config, rows, config.tabLabel);
const fileName = `${config.filePrefix}_导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
XLSX.writeFile(workbook, fileName);
return {
fileName,
count: rows.length,
};
}
export async function exportLedgerTemplateWorkbook(configKey) {
const config = getConfig(configKey);
const importFields = {
...config,
fields: config.fields.filter((fieldConfig) => fieldConfig.type !== FIELD_TYPES.FILE),
};
const workbook = createWorkbookFromRows(importFields, [], `${config.tabLabel}导入模板`);
const fileName = `${config.filePrefix}_导入模板_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
XLSX.writeFile(workbook, fileName);
return {
fileName,
};
}
function findUploadUrl(response) {
const candidates = [
response?.url,
response?.data?.url,
response?.result?.url,
response?.path,
response?.data?.path,
response?.result?.path,
response?.file?.url,
response?.data?.file?.url,
];
return candidates.find(Boolean) || '';
}
export async function uploadLedgerAttachment(file) {
const response = await uploadFile(file);
const url = findUploadUrl(response);
return {
name: file.name,
type: file.type,
size: file.size,
url,
};
}
function buildGroup(list, keyGetter, labelGetter) {
return Object.values(
list.reduce((accumulator, item) => {
const key = keyGetter(item);
if (!key) {
return accumulator;
}
if (!accumulator[key]) {
accumulator[key] = {
key,
label: labelGetter(item),
count: 0,
};
}
accumulator[key].count += 1;
return accumulator;
}, {}),
).sort((left, right) => right.count - left.count);
}
export async function loadIntegrityPartyDashboard() {
const ledgerResults = await Promise.all(
INTEGRITY_LEDGER_CONFIGS.map(async (config) => {
const result = await loadLedgerPage(config.key, {
page: 0,
pageSize: 500,
});
return {
config,
records: result.records || [],
total: result.total || 0,
error: result.error,
};
}),
);
const records = ledgerResults.flatMap((result) => result.records);
const now = dayjs();
const currentYear = now.year();
const recentThreshold = now.subtract(30, 'day');
const totalAttachmentFields = records.reduce((sum, record) => {
const config = getConfig(record.ledgerKey);
return (
sum +
config.fields.filter((fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE)
.length
);
}, 0);
const filledAttachmentFields = records.reduce((sum, record) => {
const config = getConfig(record.ledgerKey);
return (
sum +
config.fields
.filter((fieldConfig) => fieldConfig.type === FIELD_TYPES.FILE)
.filter((fieldConfig) => {
return normalizeFileList(record.rawMetadata[fieldConfig.name]).length > 0;
}).length
);
}, 0);
const ledgerGroups = ledgerResults.map((result) => ({
key: result.config.key,
label: result.config.tabLabel,
count: result.total,
}));
const yearGroups = buildGroup(
records.map((record) => ({
year: getRecordYear(record),
})),
(item) => item.year || '未填写年份',
(item) => item.year || '未填写年份',
);
const recentRecords = [...records]
.sort((left, right) =>
String(right.updatedAt || right.createdAt).localeCompare(
String(left.updatedAt || left.createdAt),
),
)
.slice(0, 8);
return {
totalCount: ledgerResults.reduce((sum, result) => sum + result.total, 0),
currentYearCount: records.filter((record) => getRecordYear(record) === String(currentYear)).length,
recentCount: records.filter((record) => {
if (!record.updatedAt) {
return false;
}
return dayjs(record.updatedAt).isAfter(recentThreshold);
}).length,
attachmentCompletionRate:
totalAttachmentFields > 0
? Math.round((filledAttachmentFields / totalAttachmentFields) * 100)
: 0,
ledgerGroups,
yearGroups,
recentRecords,
errors: ledgerResults.map((result) => result.error).filter(Boolean),
};
}
function getRecordYear(record) {
const config = getConfig(record.ledgerKey);
const yearField = config.fields.find(
(fieldConfig) => fieldConfig.name === config.yearField,
);
const value = record.rawMetadata?.[config.yearField] ?? record.metadata?.[config.yearField];
if (yearField?.type === FIELD_TYPES.DATE) {
return formatDate(value).slice(0, 4);
}
return value === null || value === undefined || value === ''
? ''
: String(value).trim();
}
import { import {
ArrowTrendingUpIcon, ArrowTrendingUpIcon,
CalendarDaysIcon,
ChartPieIcon, ChartPieIcon,
ClipboardDocumentListIcon,
DocumentTextIcon, DocumentTextIcon,
PaperClipIcon,
RectangleStackIcon, RectangleStackIcon,
TagIcon, TagIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
...@@ -13,6 +16,7 @@ import { ...@@ -13,6 +16,7 @@ import {
formatDateTime, formatDateTime,
loadCaseRecords, loadCaseRecords,
} from '../materialService'; } from '../materialService';
import { loadIntegrityPartyDashboard } from '../integrityPartyService';
function Panel({ className, children }) { function Panel({ className, children }) {
return ( return (
...@@ -358,8 +362,10 @@ function KeywordCloud({ groups = [] }) { ...@@ -358,8 +362,10 @@ function KeywordCloud({ groups = [] }) {
} }
export default function ResourceStatPage() { export default function ResourceStatPage() {
const [activeDashboard, setActiveDashboard] = useState('case');
const [cases, setCases] = useState([]); const [cases, setCases] = useState([]);
const [dashboard, setDashboard] = useState(() => buildCaseDashboard([])); const [dashboard, setDashboard] = useState(() => buildCaseDashboard([]));
const [integrityDashboard, setIntegrityDashboard] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isDemoMode, setIsDemoMode] = useState(false); const [isDemoMode, setIsDemoMode] = useState(false);
...@@ -369,16 +375,24 @@ export default function ResourceStatPage() { ...@@ -369,16 +375,24 @@ export default function ResourceStatPage() {
(async () => { (async () => {
setLoading(true); setLoading(true);
const result = await loadCaseRecords({}); const [caseResult, integrityResult] = await Promise.all([
loadCaseRecords({}),
loadIntegrityPartyDashboard(),
]);
if (cancelled) { if (cancelled) {
return; return;
} }
setCases(result.cases || []); setCases(caseResult.cases || []);
setDashboard(result.dashboard || buildCaseDashboard([])); setDashboard(caseResult.dashboard || buildCaseDashboard([]));
setError(result.error || ''); setIntegrityDashboard(integrityResult);
setIsDemoMode(Boolean(result.isDemoMode)); setError(
[caseResult.error, ...(integrityResult.errors || [])]
.filter(Boolean)
.join(';'),
);
setIsDemoMode(Boolean(caseResult.isDemoMode));
setLoading(false); setLoading(false);
})(); })();
...@@ -397,15 +411,37 @@ export default function ResourceStatPage() { ...@@ -397,15 +411,37 @@ export default function ResourceStatPage() {
<h1 className="text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl"> <h1 className="text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl">
驾驶舱 驾驶舱
</h1> </h1>
<div className="mt-5 inline-flex rounded-lg bg-white p-1 shadow-sm ring-1 ring-zinc-200">
{[
['case', '案例库统计'],
['integrity', '廉洁党建统计'],
].map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setActiveDashboard(key)}
className={clsx(
'rounded-md px-4 py-2 text-sm font-medium transition',
activeDashboard === key
? 'bg-[var(--pm-brand)] text-white shadow-sm'
: 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-950',
)}
>
{label}
</button>
))}
</div>
</div> </div>
<div className="grid gap-3 sm:w-56"> <div className="grid gap-3 sm:w-56">
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm"> <div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500"> <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
最近 7 天更新 {activeDashboard === 'case' ? '最近 7 天更新' : '最近 30 天更新'}
</div> </div>
<div className="mt-2 text-2xl font-semibold text-zinc-950"> <div className="mt-2 text-2xl font-semibold text-zinc-950">
{dashboard.recentCount} {activeDashboard === 'case'
? dashboard.recentCount
: integrityDashboard?.recentCount || 0}
</div> </div>
</div> </div>
</div> </div>
...@@ -432,6 +468,8 @@ export default function ResourceStatPage() { ...@@ -432,6 +468,8 @@ export default function ResourceStatPage() {
</div> </div>
</Panel> </Panel>
{activeDashboard === 'case' ? (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard <MetricCard
icon={DocumentTextIcon} icon={DocumentTextIcon}
...@@ -482,6 +520,23 @@ export default function ResourceStatPage() { ...@@ -482,6 +520,23 @@ export default function ResourceStatPage() {
</Panel> </Panel>
</div> </div>
<RecentCasePanel dashboard={dashboard} />
{!loading && cases.length === 0 ? (
<Panel className="p-8 text-center text-sm text-zinc-500">
当前没有可展示的案例记录。
</Panel>
) : null}
</>
) : (
<IntegrityDashboardView dashboard={integrityDashboard} loading={loading} />
)}
</div>
);
}
function RecentCasePanel({ dashboard }) {
return (
<Panel className="p-6"> <Panel className="p-6">
<SectionHeader title="最近更新" /> <SectionHeader title="最近更新" />
<div className="space-y-3"> <div className="space-y-3">
...@@ -511,12 +566,88 @@ export default function ResourceStatPage() { ...@@ -511,12 +566,88 @@ export default function ResourceStatPage() {
)} )}
</div> </div>
</Panel> </Panel>
);
}
{!loading && cases.length === 0 ? ( function IntegrityDashboardView({ dashboard, loading }) {
<Panel className="p-8 text-center text-sm text-zinc-500"> const safeDashboard =
当前没有可展示的案例记录。 dashboard || {
</Panel> totalCount: 0,
) : null} currentYearCount: 0,
recentCount: 0,
attachmentCompletionRate: 0,
ledgerGroups: [],
yearGroups: [],
recentRecords: [],
};
return (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
icon={ClipboardDocumentListIcon}
label="台账总数"
value={safeDashboard.totalCount}
tone="blue"
/>
<MetricCard
icon={CalendarDaysIcon}
label="本年度记录"
value={safeDashboard.currentYearCount}
tone="green"
/>
<MetricCard
icon={PaperClipIcon}
label="附件完整率"
value={`${safeDashboard.attachmentCompletionRate}%`}
tone="amber"
/>
<MetricCard
icon={ArrowTrendingUpIcon}
label="最近 30 天更新"
value={safeDashboard.recentCount}
tone="rose"
/>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<ChartPanel title="台账分类分布" groups={safeDashboard.ledgerGroups} type="pie" />
<ChartPanel title="年度分布" groups={safeDashboard.yearGroups} />
</div>
<Panel className="p-6">
<SectionHeader title="最近更新台账" />
<div className="space-y-3">
{safeDashboard.recentRecords.length > 0 ? (
safeDashboard.recentRecords.map((item) => (
<div
key={item.id}
className="flex flex-col gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/70 px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-zinc-950 sm:text-base">
{item.title}
</div>
<Pill>{item.ledgerLabel}</Pill>
<Pill tone={item.attachmentCount > 0 ? 'green' : 'amber'}>
附件 {item.attachmentCount}
</Pill>
</div>
<div className="mt-2 text-sm text-zinc-500">
更新于 {item.updatedAtLabel}
</div>
</div>
<div className="text-sm text-zinc-500">{item.attachmentStatus}</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">
{loading ? '正在加载廉洁党建统计...' : '暂无最近更新台账'}
</div>
)}
</div>
</Panel>
</>
); );
} }
...@@ -244,3 +244,89 @@ ...@@ -244,3 +244,89 @@
- 案例业务字段全部放入 `metadata` - 案例业务字段全部放入 `metadata`
- `metadata.keyword` 为多值字符串数组;Excel 导入如读取到 `资金管理, 异常调账`,前端需拆分为 `["资金管理", "异常调账"]` 后保存 - `metadata.keyword` 为多值字符串数组;Excel 导入如读取到 `资金管理, 异常调账`,前端需拆分为 `["资金管理", "异常调账"]` 后保存
- Excel 导出时,`metadata.keyword` 数组需恢复为逗号拼接字符串写入 `关键字` 列 - Excel 导出时,`metadata.keyword` 数组需恢复为逗号拼接字符串写入 `关键字` 列
## 6. 廉洁党建建设管理接入模式
本模块继续复用统一 `Record` 接口,6 类台账分别使用独立 subject:
- `integrity_party_training`:廉洁党建培训记录
- `integrity_party_talk`:廉洁党建谈话记录
- `integrity_party_research`:廉洁党建调研记录
- `integrity_party_meeting`:廉洁党建专题会议记录
- `integrity_party_activity`:廉洁主题活动记录
- `integrity_party_holiday_reminder`:节前廉洁提醒记录
### 6.1 创建 / 更新建议
调用:
- `POST /record`
- `PUT /record/:name`
示例:
```json
{
"subject": "integrity_party_training",
"title": "廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08",
"content_type": "text",
"content": "廉洁合规培训 · 新员工廉洁合规入职培训 · 2026-06-08",
"metadata": {
"sequence_no": 1,
"training_category": "廉洁合规培训",
"training_date": "2026-06-08T00:00:00.000Z",
"training_target": "新员工",
"training_format": "现场培训",
"lead_department": "纪检办公室",
"lecturer": "张三",
"lecture_topic": "新员工廉洁合规入职培训",
"main_content": "围绕廉洁从业要求、合规底线和典型案例开展培训。",
"participant_count": 32,
"courseware_materials": [
{
"name": "培训课件.pptx",
"type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"size": 204800,
"url": "https://example.com/file.pptx"
}
],
"sign_in_sheets": []
}
}
```
### 6.2 查询建议
调用:
- `POST /record/query`
示例:
```json
{
"subject": "integrity_party_talk",
"page": 0,
"page_size": 20,
"filter": {
"$and": [
{ "metadata.year": 2026 },
{
"$or": [
{ "metadata.person_name": { "$regex": "张三", "$options": "i" } },
{ "metadata.primary_department": { "$regex": "张三", "$options": "i" } }
]
}
]
},
"sort": "-updated_at -created_at"
}
```
注意事项:
- 业务字段全部写入 `metadata`
- 文件字段统一保存为多附件数组 `{name,type,size,url}[]`
- 日期字段页面和 Excel 展示为 `YYYY-MM-DD`,保存为 ISO 8601 字符串
- Excel 导入不处理附件字段,附件仅通过系统上传控件维护
- 当前阶段导入不做重复判断、不覆盖已有记录,全部新增
...@@ -7,6 +7,12 @@ ...@@ -7,6 +7,12 @@
当前项目当前阶段以“风险案例库”为目标,核心业务对象如下: 当前项目当前阶段以“风险案例库”为目标,核心业务对象如下:
- `risk_case_library`:案例库单条案例 - `risk_case_library`:案例库单条案例
- `integrity_party_training`:廉洁党建培训记录
- `integrity_party_talk`:廉洁党建谈话记录
- `integrity_party_research`:廉洁党建调研记录
- `integrity_party_meeting`:廉洁党建专题会议记录
- `integrity_party_activity`:廉洁主题活动记录
- `integrity_party_holiday_reminder`:节前廉洁提醒记录
--- ---
...@@ -155,3 +161,115 @@ type Record = { ...@@ -155,3 +161,115 @@ type Record = {
"case_year": 2024 "case_year": 2024
} }
``` ```
## 3.2 廉洁党建建设管理
本模块使用 6 个独立 Subject 承载不同台账类型。每条台账记录对应一条 `Record`,业务字段全部写入 `metadata`,附件字段按多文件数组存储,结构为 `{ name, type, size, url }`。
统一约定:
- 日期字段页面和 Excel 展示为 `YYYY-MM-DD`,保存时前端转换为 ISO 8601 字符串
- Excel 导入不处理附件列,附件仅通过系统上传控件维护
- `title` 由前端按各对象核心字段自动生成,不提供单独标题字段
- `sequence_no` 仅用于台账顺序和历史 Excel 对照,不作为主键
- 当前阶段不做导入重复判断,Excel 导入全部新增
### 3.2.1 廉洁党建培训记录
- **subject**: `integrity_party_training`
- **title 生成**:`培训类别 + 授课主题 + 时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 培训类别 | `training_category` | `select` | `null` | 否 | 否 | 是 | 否 | 否 | 选项:廉政党课讲授、廉洁合规培训、专题廉洁合规教育培训、新员工廉洁教育培训 |
| 时间 | `training_date` | `date` | `null` | 否 | 否 | 是 | 否 | 否 | 培训日期 |
| 培训对象 | `training_target` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 培训覆盖对象 |
| 培训形式 | `training_format` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 培训方式 |
| 牵头部门 | `lead_department` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 牵头组织部门 |
| 授课人 | `lecturer` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 授课人员 |
| 授课主题 | `lecture_topic` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 培训主题 |
| 主要内容 | `main_content` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 培训主要内容 |
| 参加人数 | `participant_count` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 实际参加人数 |
| 课件材料 | `courseware_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传的 PPT、PDF 等课件 |
| 签到表 | `sign_in_sheets` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传签到材料 |
### 3.2.2 廉洁党建谈话记录
- **subject**: `integrity_party_talk`
- **title 生成**:`姓名 + 年份 + 本年度计划谈话时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 年份 | `year` | `number` | `null` | 否 | 否 | 是 | 否 | 否 | 台账年份 |
| 姓名 | `person_name` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 谈话对象姓名 |
| 所属一级部门 | `primary_department` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 一级部门名称 |
| 岗位名称 | `position_name` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 岗位名称 |
| 岗位晋升时间 | `promotion_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 岗位晋升日期 |
| 是否党员 | `is_party_member` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否领导干部 | `is_leader` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否投资序列 | `is_investment_sequence` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 最近一期谈话时间 | `latest_talk_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 最近一次谈话日期 |
| 本年度计划谈话时间 | `planned_talk_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 本年度计划谈话日期 |
| 是否应做任前谈话 | `should_pre_appointment_talk` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 以 OA 转岗流程或发文为准 |
| 是否已做任前谈话 | `has_pre_appointment_talk` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 三态:是、否、未填写 |
| 是否纪委书记主动约谈 | `is_discipline_secretary_interview` | `boolean` | `null` | 否 | 否 | 否 | 否 | 否 | 报送口径字段 |
| 本年度实际谈话时间 | `actual_talk_date` | `date` | `null` | 否 | 否 | 否 | 否 | 否 | 本年度实际谈话日期 |
| 谈话支持材料 | `talk_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传谈话材料 |
### 3.2.3 廉洁党建调研记录
- **subject**: `integrity_party_research`
- **title 生成**:`调研单位 + 调研时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 调研时间 | `research_date` | `date` | `null` | 否 | 否 | 是 | 否 | 否 | 调研日期 |
| 调研人员及职务 | `research_staff_and_position` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 调研人员和职务描述 |
| 调研单位 | `research_unit` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 被调研单位 |
| 调研内容 | `research_content` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 调研主要内容 |
| 调研方案或调研报告 | `research_report_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传调研方案或报告 |
| 备注 | `remarks` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 备注信息 |
### 3.2.4 廉洁党建专题会议记录
- **subject**: `integrity_party_meeting`
- **title 生成**:`会议议题 + 会议时间`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 会议时间 | `meeting_date` | `date` | `null` | 否 | 否 | 是 | 否 | 否 | 会议日期 |
| 会议议题 | `meeting_topic` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 会议议题 |
| 会议主要内容 | `main_content` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 会议主要内容 |
| 支持材料 | `supporting_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传会议支持材料 |
### 3.2.5 廉洁主题活动记录
- **subject**: `integrity_party_activity`
- **title 生成**:`活动主题 + 开展年份`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 序号 | `sequence_no` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 台账顺序号 |
| 开展年份 | `year` | `number` | `null` | 否 | 否 | 是 | 否 | 否 | 活动开展年份 |
| 活动主题 | `activity_theme` | `text` | `null` | 否 | 否 | 是 | 否 | 否 | 活动主题 |
| 活动形式 | `activity_format` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 活动形式 |
| 活动成果 | `activity_result` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 活动成果描述 |
| 支持材料 | `supporting_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传活动支持材料 |
### 3.2.6 节前廉洁提醒记录
- **subject**: `integrity_party_holiday_reminder`
- **title 生成**:`年份 + 重大节假日`
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 年份 | `year` | `number` | `null` | 否 | 否 | 是 | 否 | 否 | 台账年份 |
| 重大节假日 | `holiday_name` | `select` | `null` | 否 | 否 | 是 | 否 | 否 | 选项:元旦、春节、清明、五一、端午、中秋、国庆、其他 |
| 其他节假日说明 | `holiday_other` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 选择其他时补充说明 |
| 廉洁谈话提纲上传 | `talk_outline_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传谈话提纲 |
| 廉洁提醒支持材料上传 | `reminder_materials` | `file` | `[]` | 否 | 是 | 否 | 否 | 否 | 系统内上传提醒材料 |
| 其他活动举措 | `other_measures` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 其他举措说明 |
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