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