Commit f4ec5abf authored by drigle's avatar drigle

feat: refine audit project management experience

parent 24e78137
import { Button } from '@/catalyst-ui-kit/javascript/button'; import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Badge } from '@/catalyst-ui-kit/javascript/badge';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox'; import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { import {
Dialog, Dialog,
...@@ -28,6 +29,7 @@ import { ...@@ -28,6 +29,7 @@ import {
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
PencilSquareIcon, PencilSquareIcon,
TableCellsIcon,
TrashIcon, TrashIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
...@@ -52,33 +54,27 @@ const FIELD_TYPES = [ ...@@ -52,33 +54,27 @@ const FIELD_TYPES = [
const FIELD_TYPE_LABELS = Object.fromEntries(FIELD_TYPES); const FIELD_TYPE_LABELS = Object.fromEntries(FIELD_TYPES);
function FormDialog({ open, onClose, onSaved, form }) { function FormDialog({ open, onClose, onSaved, form }) {
const [values, setValues] = useState({ title: '', name: '', description: '' }); const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setValues({ setTitle(form?.title || '');
title: form?.title || '',
name: form?.name || '',
description: form?.description || '',
});
setError(''); setError('');
} }
}, [open, form]); }, [open, form]);
const save = async () => { const save = async () => {
if (!values.title.trim() || !values.name.trim()) { if (!title.trim()) {
setError('表单标题和表单别名为必填项。'); setError('表单标题为必填项。');
return; return;
} }
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
const payload = { const payload = {
title: values.title.trim(), title: title.trim(),
name: values.name.trim(),
description: values.description.trim(),
}; };
if (form) { if (form) {
await updateDForm(form.name, payload); await updateDForm(form.name, payload);
...@@ -97,20 +93,12 @@ function FormDialog({ open, onClose, onSaved, form }) { ...@@ -97,20 +93,12 @@ function FormDialog({ open, onClose, onSaved, form }) {
return ( return (
<Dialog open={Boolean(open)} onClose={saving ? () => {} : onClose} size="lg"> <Dialog open={Boolean(open)} onClose={saving ? () => {} : onClose} size="lg">
<DialogTitle>{form ? '编辑表单' : '新建表单'}</DialogTitle> <DialogTitle>{form ? '编辑表单' : '新建表单'}</DialogTitle>
<DialogDescription>维护表单名称、别名与说明。</DialogDescription> <DialogDescription>维护表单名称,内部标识由系统自动生成。</DialogDescription>
<DialogBody> <DialogBody>
<div className="space-y-5"> <div className="space-y-5">
<Field> <Field>
<Label>表单标题 <span className="text-red-600">*</span></Label> <Label>表单标题 <span className="text-red-600">*</span></Label>
<Input value={values.title} onChange={(event) => setValues((current) => ({ ...current, title: event.target.value }))} /> <Input value={title} onChange={(event) => setTitle(event.target.value)} />
</Field>
<Field>
<Label>表单别名 <span className="text-red-600">*</span></Label>
<Input disabled={Boolean(form)} value={values.name} onChange={(event) => setValues((current) => ({ ...current, name: event.target.value }))} />
</Field>
<Field>
<Label>表单描述</Label>
<Textarea rows={4} value={values.description} onChange={(event) => setValues((current) => ({ ...current, description: event.target.value }))} />
</Field> </Field>
{error ? <div role="alert" className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null} {error ? <div role="alert" className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
</div> </div>
...@@ -426,6 +414,8 @@ export default function DynamicFormManager() { ...@@ -426,6 +414,8 @@ export default function DynamicFormManager() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(20);
const [formDialogOpen, setFormDialogOpen] = useState(false); const [formDialogOpen, setFormDialogOpen] = useState(false);
const [editingForm, setEditingForm] = useState(null); const [editingForm, setEditingForm] = useState(null);
const [fieldsForm, setFieldsForm] = useState(null); const [fieldsForm, setFieldsForm] = useState(null);
...@@ -449,47 +439,87 @@ export default function DynamicFormManager() { ...@@ -449,47 +439,87 @@ export default function DynamicFormManager() {
reload(); reload();
}, [reload]); }, [reload]);
const records = useMemo(() => forms const filteredRecords = useMemo(() => forms
.filter((form) => `${form.title} ${form.name} ${form.description || ''}`.toLowerCase().includes(keyword.trim().toLowerCase())) .filter((form) => `${form.title}`.toLowerCase().includes(keyword.trim().toLowerCase()))
.map((form) => ({ .map((form) => ({
id: form._id || form.name, id: form._id || form.name,
name: form.name, name: form.name,
title: form.title, title: form.title,
description: form.description || '-',
fieldCount: form.fields?.length || 0, fieldCount: form.fields?.length || 0,
raw: form, raw: form,
})), [forms, keyword]); })), [forms, keyword]);
const records = useMemo(
() => filteredRecords.slice(page * pageSize, (page + 1) * pageSize),
[filteredRecords, page, pageSize],
);
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(filteredRecords.length / pageSize) - 1);
if (page > lastPage) {
setPage(lastPage);
}
}, [filteredRecords.length, page, pageSize]);
const handleKeywordChange = (value) => {
setKeyword(value);
setPage(0);
};
return ( return (
<> <>
<RecordWorkbench <RecordWorkbench
summaryItems={[
{ label: '表单总数', value: forms.length, hint: '动态表单配置', tone: 'default' },
{ label: '筛选结果', value: records.length, hint: '当前搜索结果', tone: 'emerald' },
{ label: '字段总数', value: forms.reduce((sum, form) => sum + (form.fields?.length || 0), 0), hint: '全部表单字段', tone: 'amber' },
]}
title="项目表单" title="项目表单"
description="使用当前项目工作台维护动态表单和字段配置。" description={`共 ${filteredRecords.length} 个表单,可维护表单信息和字段结构。`}
records={records} records={records}
columns={[ columns={[
{ field: 'title', label: '表单名称', width: 260, fieldDef: { type: 'text', label: '表单名称' } }, {
{ field: 'name', label: '表单别名', width: 220, fieldDef: { type: 'text', label: '表单别名' } }, field: 'title',
{ field: 'description', label: '表单描述', width: 360, fieldDef: { type: 'text', label: '表单描述' } }, label: '表单名称',
{ field: 'fieldCount', label: '字段数量', width: 120, fieldDef: { type: 'number', label: '字段数量' } }, width: 420,
fieldDef: { type: 'text', label: '表单名称' },
render: (record) => (
<div className="truncate text-sm font-medium text-slate-900" title={record.title}>
{record.title || '未命名表单'}
</div>
),
},
{
field: 'fieldCount',
label: '字段数量',
width: 140,
fieldDef: { type: 'number', label: '字段数量' },
render: (record) => (
<Badge color="zinc" title={`${record.fieldCount} 个字段`}>
{record.fieldCount}
</Badge>
),
},
]} ]}
selectable={false} selectable={false}
filters={{ keyword }} filters={{ keyword }}
filterSchema={[{ key: 'keyword', label: '搜索表单', type: 'text', placeholder: '表单名称、别名或说明' }]} filterSchema={[{ key: 'keyword', label: '搜索表单', type: 'text', placeholder: '表单名称' }]}
onFiltersChange={(_, value) => setKeyword(value)} onFiltersChange={(_, value) => handleKeywordChange(value)}
onClearFilters={() => setKeyword('')} onClearFilters={() => handleKeywordChange('')}
toolbarActions={[ toolbarActions={[
{ key: 'create-form', label: '新建表单', variant: 'solid', tone: 'primary', onClick: () => { setEditingForm(null); setFormDialogOpen(true); } }, { key: 'create-form', label: '新建表单', variant: 'solid', tone: 'primary', onClick: () => { setEditingForm(null); setFormDialogOpen(true); } },
]} ]}
rowActions={[ rowActions={[
{ key: 'fields', label: '字段管理', onClick: ({ record }) => setFieldsForm(record.raw) }, { key: 'fields', label: '字段管理', icon: TableCellsIcon, onClick: ({ record }) => setFieldsForm(record.raw) },
{ key: 'edit', label: '编辑表单', icon: PencilSquareIcon, onClick: ({ record }) => { setEditingForm(record.raw); setFormDialogOpen(true); } }, { key: 'edit', label: '编辑表单', icon: PencilSquareIcon, onClick: ({ record }) => { setEditingForm(record.raw); setFormDialogOpen(true); } },
{ key: 'delete', label: '删除表单', icon: TrashIcon, tone: 'danger', onClick: ({ record }) => setDeleteForm(record.raw) }, { key: 'delete', label: '删除表单', icon: TrashIcon, tone: 'danger', onClick: ({ record }) => setDeleteForm(record.raw) },
]} ]}
pagination={{
page,
pageSize,
totalRecords: filteredRecords.length,
pageSizeOptions: [10, 20, 50, 100],
onPageChange: setPage,
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
},
}}
states={{ loading, error, success, emptyTitle: '暂无项目表单', emptyDescription: '使用“新建表单”开始配置。' }} states={{ loading, error, success, emptyTitle: '暂无项目表单', emptyDescription: '使用“新建表单”开始配置。' }}
/> />
......
...@@ -48,12 +48,14 @@ export default function SubjectRecordWorkbench({ ...@@ -48,12 +48,14 @@ export default function SubjectRecordWorkbench({
description, description,
readonly = false, readonly = false,
allowCreate = true, allowCreate = true,
allowEdit = true,
allowClone = true, allowClone = true,
allowDelete = true, allowDelete = true,
query, query,
buildSummaryItems, buildSummaryItems,
toolbarActions = [], toolbarActions = [],
rowActions = [], rowActions = [],
hiddenColumnFields = [],
detailDrawerProps = {}, detailDrawerProps = {},
emptyDescription, emptyDescription,
}) { }) {
...@@ -120,6 +122,10 @@ export default function SubjectRecordWorkbench({ ...@@ -120,6 +122,10 @@ export default function SubjectRecordWorkbench({
[records, filters, activeView, subject], [records, filters, activeView, subject],
); );
const filteredRecords = configuredView.filteredRecords || []; const filteredRecords = configuredView.filteredRecords || [];
const visibleColumns = useMemo(() => {
const hiddenFields = new Set(hiddenColumnFields);
return (configuredView.columns || []).filter((column) => !hiddenFields.has(column.field));
}, [configuredView.columns, hiddenColumnFields]);
const totalPages = Math.max(1, Math.ceil(filteredRecords.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filteredRecords.length / pageSize));
const safePage = Math.min(page, totalPages - 1); const safePage = Math.min(page, totalPages - 1);
const pageRecords = filteredRecords.slice(safePage * pageSize, (safePage + 1) * pageSize); const pageRecords = filteredRecords.slice(safePage * pageSize, (safePage + 1) * pageSize);
...@@ -254,7 +260,7 @@ export default function SubjectRecordWorkbench({ ...@@ -254,7 +260,7 @@ export default function SubjectRecordWorkbench({
variant: 'plain', variant: 'plain',
onClick: ({ record, openDetail }) => openDetail(record), onClick: ({ record, openDetail }) => openDetail(record),
}, },
...(!readonly ...(allowEdit && !readonly
? [{ ? [{
key: 'edit', key: 'edit',
label: '编辑', label: '编辑',
...@@ -313,7 +319,8 @@ export default function SubjectRecordWorkbench({ ...@@ -313,7 +319,8 @@ export default function SubjectRecordWorkbench({
}} }}
title={title} title={title}
description={description} description={description}
columns={configuredView.columns || []} columns={visibleColumns}
columnGroups={activeView?.settings?.groups || []}
records={pageRecords} records={pageRecords}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectionChange={setSelectedIds} onSelectionChange={setSelectedIds}
......
import { Button } from '@/catalyst-ui-kit/javascript/button'; import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Badge } from '@/catalyst-ui-kit/javascript/badge';
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox'; import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { Input } from '@/catalyst-ui-kit/javascript/input'; import { Input } from '@/catalyst-ui-kit/javascript/input';
import { Select } from '@/catalyst-ui-kit/javascript/select'; import { Select } from '@/catalyst-ui-kit/javascript/select';
...@@ -10,9 +11,10 @@ import { ...@@ -10,9 +11,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/catalyst-ui-kit/javascript/table'; } from '@/catalyst-ui-kit/javascript/table';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue, getFormValueSummaries } from '@/utils/subjectView';
import { import {
ChevronRightIcon, ChevronRightIcon,
ClipboardDocumentListIcon,
DocumentDuplicateIcon, DocumentDuplicateIcon,
EyeIcon, EyeIcon,
TrashIcon, TrashIcon,
...@@ -268,8 +270,48 @@ function defaultGetRecordLabel(record) { ...@@ -268,8 +270,48 @@ function defaultGetRecordLabel(record) {
} }
function defaultRenderCell(record, column) { function defaultRenderCell(record, column) {
const sourceRecord = record?.raw || record; const mappedValue = getFieldValue(record, column.field);
const value = getFieldValue(sourceRecord, column.field); const value = mappedValue === undefined
? getFieldValue(record?.raw, column.field)
: mappedValue;
const fieldType = column.fieldDef?.type;
if (fieldType === 'long_text' || fieldType === 'rich_text') {
const text = formatFieldValue(value, column.fieldDef);
return text ? (
<div
className="line-clamp-3 whitespace-pre-wrap break-words text-sm leading-5 text-slate-700"
title={text}
>
{text}
</div>
) : <span className="text-sm text-zinc-400">-</span>;
}
if (fieldType === 'form') {
const summaries = getFormValueSummaries(value, column.fieldDef?.label || '表单');
if (summaries.length === 0) {
return <span className="text-sm text-zinc-400">-</span>;
}
return (
<div className="flex max-w-full flex-wrap gap-1.5">
{summaries.map((summary) => (
<Badge
key={summary.key}
color="blue"
className="max-w-full py-1"
title={`${summary.label},${summary.rowCount} 行数据`}
>
<ClipboardDocumentListIcon className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{summary.label}</span>
<span className="shrink-0 text-blue-600/70">({summary.rowCount})</span>
</Badge>
))}
</div>
);
}
return ( return (
<div className="whitespace-normal break-words text-sm text-slate-700"> <div className="whitespace-normal break-words text-sm text-slate-700">
{formatFieldValue(value, column.fieldDef) || '-'} {formatFieldValue(value, column.fieldDef) || '-'}
......
:root { :root {
--pm-brand: #2563eb; --pm-brand: #b42318;
--pm-brand-strong: #1d4ed8; --pm-brand-strong: #8f1d14;
--pm-accent: #059669; --pm-accent: #059669;
--pm-surface: #ffffff; --pm-surface: #ffffff;
--pm-bg: #f8fafc; --pm-bg: #f8fafc;
...@@ -17,6 +17,16 @@ body, ...@@ -17,6 +17,16 @@ body,
} }
body { body {
--color-blue-50: #fef2f2;
--color-blue-100: #fee2e2;
--color-blue-200: #fecaca;
--color-blue-300: #fca5a5;
--color-blue-400: #f87171;
--color-blue-500: #dc2626;
--color-blue-600: #b42318;
--color-blue-700: #991b1b;
--color-blue-800: #7f1d1d;
--color-blue-900: #450a0a;
margin: 0; margin: 0;
background: var(--pm-bg); background: var(--pm-bg);
color: var(--pm-text); color: var(--pm-text);
......
import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench'; import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import { import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
ArrowDownTrayIcon,
ArchiveBoxArrowDownIcon,
} from '@heroicons/react/24/outline';
import { import {
archiveAuditProjects, archiveAuditProjects,
AUDIT_PROJECT_SUBJECT, AUDIT_PROJECT_SUBJECT,
...@@ -15,6 +12,8 @@ import { ...@@ -15,6 +12,8 @@ import {
transitionAuditProject, transitionAuditProject,
} from './AuditProjectService'; } from './AuditProjectService';
const HIDDEN_LIST_COLUMNS = ['title'];
function hasStatus(record, statuses) { function hasStatus(record, statuses) {
return statuses.includes(record?.metadata?.project_status); return statuses.includes(record?.metadata?.project_status);
} }
...@@ -89,25 +88,11 @@ export default function AuditProjectListPage() { ...@@ -89,25 +88,11 @@ export default function AuditProjectListPage() {
<SubjectRecordWorkbench <SubjectRecordWorkbench
subjectName={AUDIT_PROJECT_SUBJECT} subjectName={AUDIT_PROJECT_SUBJECT}
title="审计项目" title="审计项目"
description="按线上视图配置展示审计项目,支持详情维护、状态流转、导出和一键归档。" description="按线上视图配置展示审计项目,支持详情维护、状态流转、导出和批量归档。"
buildSummaryItems={({ records, filteredRecords, selectedIds }) => { allowEdit={false}
const completed = records.filter((record) => allowClone={false}
['整改', '归档'].includes(record.raw?.metadata?.project_status), hiddenColumnFields={HIDDEN_LIST_COLUMNS}
).length; buildSummaryItems={() => []}
return [
{ label: '项目总数', value: records.length, hint: '全部审计项目', tone: 'default' },
{ label: '已完成', value: completed, hint: '整改或归档项目', tone: 'emerald' },
{ label: '未完成', value: records.length - completed, hint: '尚未进入整改或归档', tone: 'amber' },
{
label: '完成率',
value: records.length ? `${Math.round((completed / records.length) * 100)}%` : '0%',
hint: '已完成项目占比',
tone: 'rose',
},
{ label: '筛选结果', value: filteredRecords.length, hint: '当前视图和筛选', tone: 'default' },
{ label: '当前已选', value: selectedIds.length, hint: '可执行批量操作', tone: 'default' },
];
}}
toolbarActions={[ toolbarActions={[
{ {
key: 'archive-selected', key: 'archive-selected',
...@@ -126,24 +111,6 @@ export default function AuditProjectListPage() { ...@@ -126,24 +111,6 @@ export default function AuditProjectListPage() {
}, },
}, },
]} ]}
rowActions={[
{
key: 'archive',
label: '一键归档',
icon: ArchiveBoxArrowDownIcon,
variant: 'plain',
onClick: async ({ record, reload, setSuccess, setError }) => {
setError('');
try {
await archiveAuditProjects([record]);
setSuccess('项目快照归档成功。');
await reload({ keepFeedback: true });
} catch (error) {
setError(error?.message || '归档失败,请稍后重试。');
}
},
},
]}
detailDrawerProps={{ detailDrawerProps={{
width: 'min(1180px, 96vw)', width: 'min(1180px, 96vw)',
isViewDisabled: isFutureAuditProjectView, isViewDisabled: isFutureAuditProjectView,
......
import { Avatar } from '@/catalyst-ui-kit/javascript/avatar'; import { Avatar } from '@/catalyst-ui-kit/javascript/avatar';
import { Button } from '@/catalyst-ui-kit/javascript/button'; import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Navbar, NavbarSection } from '@/catalyst-ui-kit/javascript/navbar'; import { Navbar, NavbarSection } from '@/catalyst-ui-kit/javascript/navbar';
import * as Headless from '@headlessui/react';
import { import {
Sidebar, Sidebar,
SidebarBody, SidebarBody,
SidebarFooter, SidebarFooter,
SidebarHeader, SidebarHeader,
SidebarHeading,
SidebarItem, SidebarItem,
SidebarLabel, SidebarLabel,
SidebarSection, SidebarSection,
...@@ -17,9 +17,9 @@ import xyLogoSmall from '@/assets/xy-logo_small.png'; ...@@ -17,9 +17,9 @@ import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { import {
ArchiveBoxIcon,
ArrowRightStartOnRectangleIcon, ArrowRightStartOnRectangleIcon,
ChartBarSquareIcon, ChartBarSquareIcon,
ChevronDownIcon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
DocumentDuplicateIcon, DocumentDuplicateIcon,
Squares2X2Icon, Squares2X2Icon,
...@@ -27,39 +27,76 @@ import { ...@@ -27,39 +27,76 @@ import {
import { useContext } from 'react'; import { useContext } from 'react';
import { Outlet, useLocation } from 'umi'; import { Outlet, useLocation } from 'umi';
const MENU_GROUPS = [ const DASHBOARD_ITEM = {
{
title: '审计项目管理',
items: [
{
label: '驾驶舱', label: '驾驶舱',
path: '/audit_project_table/dashboard', path: '/audit_project_table/dashboard',
icon: ChartBarSquareIcon, icon: ChartBarSquareIcon,
}, };
const MENU_GROUPS = [
{
title: '开展审计工作',
icon: ClipboardDocumentListIcon,
items: [
{ {
label: '审计项目管理', label: '审计项目管理',
path: '/audit_project', path: '/audit_project',
icon: ClipboardDocumentListIcon,
}, },
{ {
label: '往期项目归档', label: '往期项目归档',
path: '/audit_project_archive', path: '/audit_project_archive',
icon: ArchiveBoxIcon,
}, },
], ],
}, },
{ {
title: '表单配置及维护', title: '表单配置及维护',
icon: DocumentDuplicateIcon,
items: [ items: [
{ {
label: '项目表单', label: '项目表单',
path: '/project_form', path: '/project_form',
icon: DocumentDuplicateIcon,
}, },
], ],
}, },
]; ];
function NavigationItem({ item, currentPath }) {
const Icon = item.icon;
return (
<SidebarItem href={item.path} current={currentPath === item.path}>
{Icon ? <Icon data-slot="icon" className="!fill-none stroke-current" /> : null}
<SidebarLabel>{item.label}</SidebarLabel>
</SidebarItem>
);
}
function NavigationGroup({ group, currentPath }) {
const GroupIcon = group.icon;
return (
<Headless.Disclosure defaultOpen>
{({ open }) => (
<>
<Headless.DisclosureButton className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm font-medium text-white transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30">
<GroupIcon className="size-5 shrink-0 text-zinc-400" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-white">{group.title}</span>
<ChevronDownIcon
className={open ? 'size-4 shrink-0 rotate-180 text-zinc-500 transition-transform' : 'size-4 shrink-0 text-zinc-500 transition-transform'}
aria-hidden="true"
/>
</Headless.DisclosureButton>
<Headless.DisclosurePanel className="ml-5 flex flex-col gap-0.5 border-l border-white/10 pl-3">
{group.items.map((item) => (
<NavigationItem key={item.path} item={item} currentPath={currentPath} />
))}
</Headless.DisclosurePanel>
</>
)}
</Headless.Disclosure>
);
}
function Navigation({ location, userInfo, siteInfo }) { function Navigation({ location, userInfo, siteInfo }) {
const appTitle = siteInfo?.title || siteInfo?.name || '审计项目管理'; const appTitle = siteInfo?.title || siteInfo?.name || '审计项目管理';
const currentPath = location.pathname.replace(/^\/audit_project_manager(?=\/|$)/, '') || '/'; const currentPath = location.pathname.replace(/^\/audit_project_manager(?=\/|$)/, '') || '/';
...@@ -79,24 +116,12 @@ function Navigation({ location, userInfo, siteInfo }) { ...@@ -79,24 +116,12 @@ function Navigation({ location, userInfo, siteInfo }) {
</SidebarHeader> </SidebarHeader>
<SidebarBody> <SidebarBody>
<SidebarSection>
<NavigationItem item={DASHBOARD_ITEM} currentPath={currentPath} />
{MENU_GROUPS.map((group) => ( {MENU_GROUPS.map((group) => (
<SidebarSection key={group.title}> <NavigationGroup key={group.title} group={group} currentPath={currentPath} />
<SidebarHeading className="text-zinc-500">{group.title}</SidebarHeading>
{group.items.map((item) => {
const Icon = item.icon;
return (
<SidebarItem
key={item.path}
href={item.path}
current={currentPath === item.path}
>
<Icon data-slot="icon" />
<SidebarLabel>{item.label}</SidebarLabel>
</SidebarItem>
);
})}
</SidebarSection>
))} ))}
</SidebarSection>
<SidebarSpacer /> <SidebarSpacer />
</SidebarBody> </SidebarBody>
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
| 页面 | 路由 | 用途 | | 页面 | 路由 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| 驾驶舱 | `/audit_project_table/dashboard` | 查看项目完成率、阶段分布、逾期和临期项目 | | 驾驶舱 | `/audit_project_table/dashboard` | 查看项目完成率、阶段分布、逾期和临期项目 |
| 审计项目管理 | `/audit_project` | 查询、维护、复制、删除、归档和推进项目 | | 审计项目管理 | `/audit_project` | 查询、维护、删除和推进项目 |
| 往期项目归档 | `/audit_project_archive` | 只读查看审计项目历史快照 | | 往期项目归档 | `/audit_project_archive` | 只读查看审计项目历史快照 |
| 项目表单 | `/project_form` | 维护动态表单及字段 | | 项目表单 | `/project_form` | 维护动态表单及字段 |
| 审计项目填写表 | `/audit_project_form` | 兼容外部填写和复核链接 | | 审计项目填写表 | `/audit_project_form` | 兼容外部填写和复核链接 |
...@@ -15,14 +15,18 @@ ...@@ -15,14 +15,18 @@
配置中的 `audit_indicator`、`risk_indicator`、`audit_process`、`audit_report` 配置中的 `audit_indicator`、`risk_indicator`、`audit_process`、`audit_report`
没有菜单入口,属于复制配置残留,不注册为本应用路由。 没有菜单入口,属于复制配置残留,不注册为本应用路由。
侧栏将“驾驶舱”作为独立入口;“审计项目管理”和“往期项目归档”归入可展开的“开展审计工作”,“项目表单”归入可展开的“表单配置及维护”。两个分组默认展开,并支持手动收起。模块主题色与 `problem_rectification` 保持一致,使用品牌红 `#b42318` 统一导航选中态、主按钮、复选框、当前视图和焦点反馈。
## 2. 项目列表与详情 ## 2. 项目列表与详情
1. 页面通过 `loadSubject`、`getSubjectView` 和 `POST /record/query` 读取字段、视图和记录。 1. 页面通过 `loadSubject`、`getSubjectView` 和 `POST /record/query` 读取字段、视图和记录。
2. 表格列、视图限制、排序和选择字段选项来自服务端配置。 2. 表格列、视图限制、排序、选择字段选项和字段分组来自服务端配置;字段分组读取当前 Grid View 的 `settings.groups`,按配置展示分组表头并支持展开或收起。审计项目管理列表不展示通用 `title` 列,项目名称及其他业务字段仍按 View 配置展示。
3. 新建和编辑时业务字段统一写入 `metadata`;引用和用户字段只提交 ID,日期提交 ISO 8601。 3. 新建和编辑时业务字段统一写入 `metadata`;引用和用户字段只提交 ID,日期提交 ISO 8601。
4. 编辑模式下主键字段禁用;必填字段按 Subject 配置校验。 4. 编辑模式下主键字段禁用;必填字段按 Subject 配置校验。
5. 复制调用 `POST /record/clone`;删除调用 `POST /record/batch/remove`,默认软删除。 5. 删除调用 `POST /record/batch/remove`,默认软删除;列表行内不提供编辑、复制或归档按钮,字段维护统一从详情侧栏进入。
6. 详情抽屉按可访问的 detail 视图显示字段,并支持字段内联维护。 6. 详情抽屉按可访问的 detail 视图显示字段,并支持字段内联维护。
7. 列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。
8. 列表中的 `long_text` 和 `rich_text` 字段最多展示三行,超出内容省略;完整内容通过悬浮提示或项目详情查看,避免单条长文本拉高整行表格。
## 3. 状态流转 ## 3. 状态流转
...@@ -49,14 +53,12 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -49,14 +53,12 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
`audit_report_template` 文档。需求材料和问题清单导出为 Excel;核实书按数据行生成 `audit_report_template` 文档。需求材料和问题清单导出为 Excel;核实书按数据行生成
多个文档并打包为 ZIP。缺少模板或可导出数据时展示错误,不生成空文件。 多个文档并打包为 ZIP。缺少模板或可导出数据时展示错误,不生成空文件。
## 5. 一键归档 ## 5. 往期项目归档
1. 用户在项目行或批量选择后执行“一键归档”。
2. 页面把项目 `metadata` 复制为 `audit_project_history` 记录;只按 Subject 定义把 `user/reference` 字段转换为 ID,保留表单和文件对象结构。
3. 页面通过 `POST /record/import` 批量写入历史快照;接口未返回任何历史记录 ID 时展示错误。
4. `/audit_project_archive` 直接查询并展示 `audit_project_history`,不再读写已废弃的 `audit_project_archive` Subject。
归档操作生成快照,不删除原项目,也不自动修改原项目状态。 - `/audit_project_archive` 直接查询并展示已有的 `audit_project_history` 历史快照,不再读写已废弃的 `audit_project_archive` Subject。
- 审计项目列表右上角保留批量“一键归档”:用户选择一条或多条项目后,页面把项目 `metadata` 复制为 `audit_project_history` 记录,并通过 `POST /record/import` 写入历史快照。
- 批量归档未选择记录时按钮禁用;接口未返回任何历史记录 ID 时展示错误。归档只生成快照,不删除或修改原审计项目。
- 列表行内不提供复制或单条归档入口,已有历史快照继续保留并只读展示。
## 6. 驾驶舱口径 ## 6. 驾驶舱口径
...@@ -85,13 +87,15 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -85,13 +87,15 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
项目表单使用独立 `/form` API。表单列表和字段列表复用 `RecordWorkbench`,不再使用 项目表单使用独立 `/form` API。表单列表和字段列表复用 `RecordWorkbench`,不再使用
旧 `form_grid` 多维表格。 旧 `form_grid` 多维表格。
- 表单支持新建、编辑、删除和进入字段管理。 - 表单支持新建、编辑、删除和进入字段管理;新建和编辑只维护表单标题,不提供描述或别名字段。新建时内部 `name` 由服务端自动生成,编辑时保留已有 `name`。列表只展示表单标题,字段数量以标签展示,字段管理使用表格图标入口。
- 表单列表默认每页展示 20 条,搜索表单时自动回到第一页,并支持切换每页 10、20、50 或 100 条。
- 字段支持新建、编辑、删除、上移、下移、Excel 导入和导出。 - 字段支持新建、编辑、删除、上移、下移、Excel 导入和导出。
- 字段类型、必填、多值、主键和 `settings` 由表单字段接口保存。 - 字段类型、必填、多值、主键和 `settings` 由表单字段接口保存。
- `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。 - `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。
- 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:左侧按 `settings.group` 展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。 - 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:左侧按 `settings.group` 展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。
- 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。 - 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。
- 表单数据行使用通用表格展示,通过 Catalyst 弹窗新增或编辑;侧栏中的修改先作为草稿保留,点击“保存表单”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。 - 表单数据行使用通用表格展示,通过 Catalyst 弹窗新增或编辑;侧栏中的修改先作为草稿保留,点击“保存表单”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。
- 业务列表中的 `form` 字段只显示“表单名称(数据行数)”摘要标签,不直接展示 `{ form, data, settings }` JSON;多值表单按实例分别展示。
- 删除为不可恢复操作,执行前必须二次确认;接口失败时保留当前页面并展示错误。 - 删除为不可恢复操作,执行前必须二次确认;接口失败时保留当前页面并展示错误。
## 9. 权限与限制 ## 9. 权限与限制
......
...@@ -280,16 +280,17 @@ Query:`calculated=true|false` ...@@ -280,16 +280,17 @@ Query:`calculated=true|false`
- PUT `/form/:name`:更新表单 - PUT `/form/:name`:更新表单
- DELETE `/form/:name`:删除表单 - DELETE `/form/:name`:删除表单
表单创建和更新请求体: 项目表单页面创建和更新请求体:
```json ```json
{ {
"title": "审计项目表单", "title": "审计项目表单"
"name": "audit_project_form",
"description": "表单说明"
} }
``` ```
创建时只提交 `title`,`name` 由服务端自动生成;编辑时使用已有
`name` 作为 URL 路径参数,只更新 `title`,页面不提供 `name` 编辑入口。
### 5.2 表单字段 ### 5.2 表单字段
- GET `/form/:name/field`:查询全部字段 - GET `/form/:name/field`:查询全部字段
......
...@@ -418,6 +418,30 @@ export function formatDateText(value) { ...@@ -418,6 +418,30 @@ export function formatDateText(value) {
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text; return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
} }
export function getFormValueSummaries(value, fallbackLabel = '表单') {
return toArray(value)
.filter(Boolean)
.map((instance, index) => {
const normalizedInstance = typeof instance === 'object' && !Array.isArray(instance)
? instance
: { form: instance, data: [] };
const form = normalizedInstance.form;
const label = form && typeof form === 'object'
? form.title || form.label || form.name || fallbackLabel
: fallbackLabel;
const formKey = form && typeof form === 'object'
? form._id || form.name || label
: label;
return {
key: `${formKey}-${index}`,
label,
rowCount: Array.isArray(normalizedInstance.data) ? normalizedInstance.data.length : 0,
};
});
}
export function formatFieldValue(value, fieldDef) { export function formatFieldValue(value, fieldDef) {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return ''; return '';
...@@ -464,6 +488,12 @@ export function formatFieldValue(value, fieldDef) { ...@@ -464,6 +488,12 @@ export function formatFieldValue(value, fieldDef) {
.join('、'); .join('、');
} }
if (fieldDef?.type === 'form') {
return getFormValueSummaries(value, fieldDef?.label || '表单')
.map((item) => `${item.label}(${item.rowCount})`)
.join('、');
}
if (typeof value === 'object') { if (typeof value === 'object') {
return JSON.stringify(value); return JSON.stringify(value);
} }
......
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