Commit 22d96165 authored by drigle's avatar drigle

feat: configure table field count and label display

parent 19c9905e
......@@ -145,6 +145,8 @@ export default function SubjectRecordWorkbench({
const [records, setRecords] = useState([]);
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
......@@ -183,12 +185,16 @@ export default function SubjectRecordWorkbench({
setTotalRecords(resources.totalCount || 0);
setSubject(resources.subject || null);
setViews(resources.views || []);
setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
if (requestId !== requestSequence.current) return;
setRecords([]);
setTotalRecords(0);
setSubject(null);
setViews([]);
setTableDisplayConfig({});
setTableDisplayConfigError('');
setError(requestError?.message || '列表加载失败,请稍后重试。');
} finally {
if (requestId === requestSequence.current) {
......@@ -234,8 +240,8 @@ export default function SubjectRecordWorkbench({
);
const hasAccessibleView = Boolean(activeView);
const configuredView = useMemo(
() => buildConfiguredWorkbenchView(records, {}, activeView, subject),
[records, activeView, subject],
() => buildConfiguredWorkbenchView(records, {}, activeView, subject, tableDisplayConfig),
[records, activeView, subject, tableDisplayConfig],
);
const filteredRecords = configuredView.filteredRecords || [];
const visibleColumns = useMemo(() => {
......@@ -465,6 +471,7 @@ export default function SubjectRecordWorkbench({
),
} : null}
states={{
warning: tableDisplayConfigError,
loading,
error,
success,
......
import { Button, Chip, Tooltip } from '@heroui/react';
import { DataTablePrimaryAction, DataTableText } from './DataTable';
import { getTableFieldDisplay } from '@/utils/tableFieldDisplay';
export default function TableFieldValue({ value, column, onPress }) {
const display = getTableFieldDisplay(value, column);
if (display.showCount) {
const count = (
<Chip size="sm" aria-label={`${column.label || '关联条目'}:${display.count} 项`}>
{display.count}
</Chip>
);
return display.labels.length > 0 ? (
<Tooltip>
{onPress ? (
<Button variant="ghost" size="sm" onPress={onPress} aria-label={`${column.label || '关联条目'}:${display.count} 项,查看详情`}>
{count}
</Button>
) : (
<Tooltip.Trigger aria-label={`${column.label || '关联条目'}:${display.count} 项,查看标题`}>
{count}
</Tooltip.Trigger>
)}
<Tooltip.Content>
<div className="max-h-64 max-w-80 overflow-auto whitespace-pre-wrap break-words">
{display.labels.join('、')}
</div>
</Tooltip.Content>
</Tooltip>
) : onPress ? (
<Button variant="ghost" size="sm" onPress={onPress} aria-label={`${column.label || '关联条目'}:${display.count} 项,查看详情`}>
{count}
</Button>
) : count;
}
if (onPress) return <DataTablePrimaryAction text={display.text || '-'} onPress={onPress} />;
return <DataTableText text={display.text || '-'} maxLines={2} showTooltip />;
}
......@@ -26,6 +26,8 @@ import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker';
import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect';
import UserSearchSelect from '@/components/UserSearchSelect';
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import {
Alert,
......@@ -305,6 +307,9 @@ function getFilterPlaceholder(field) {
function defaultRenderCell(record, column) {
const value = getRecordFieldValue(record, column.field);
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={value} column={column} />;
}
const text = formatFieldValue(value, column.fieldDef) || '-';
if (column.fieldDef?.type === 'user') {
......@@ -894,6 +899,11 @@ export default function RecordWorkbench({
}
}
if (columnIndex === 0 && detailPanel && getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={getRecordFieldValue(row.record, column.field)} column={column}
onPress={() => row.rowContext.openDetail(row.record)} />;
}
if (columnIndex === 0 && detailPanel) {
const value = getRecordFieldValue(row.record, column.field);
const text = formatFieldValue(value, column.fieldDef) || '-';
......@@ -1192,6 +1202,12 @@ export default function RecordWorkbench({
</Alert>
) : null}
{states?.warning ? (
<Alert status="warning">
<Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content>
</Alert>
) : null}
<div ref={tableScrollRef} className={tableContentClass}>
<DataTable
ariaLabel={title || '业务数据列表'}
......
import { loadApplicationTableDisplayConfig } from '@/services/tableFieldDisplayService';
import { countRecords, getSubjectView, loadRecord, loadSubject, queryRecords } from '@/services/DataService';
import {
buildViewColumns,
......@@ -30,9 +31,10 @@ export function normalizeArchiveBatchRecord(record) {
}
export async function loadArchiveWorkbenchResources() {
const [archiveSubject, archiveViews] = await Promise.all([
const [archiveSubject, archiveViews, tableDisplayConfig] = await Promise.all([
loadSubject(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
getSubjectView(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
loadApplicationTableDisplayConfig(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
]);
const accessibleViews = buildArchiveGridViews(archiveViews || []);
const archiveRecordsResult = accessibleViews.length > 0
......@@ -50,6 +52,7 @@ export async function loadArchiveWorkbenchResources() {
records,
subject: archiveSubject,
views: archiveViews || [],
...tableDisplayConfig,
};
}
......@@ -70,11 +73,11 @@ export function buildArchiveGridViews(views = []) {
return isViewAccessible(fallbackView) ? [fallbackView] : [];
}
export function buildArchiveColumns(view, subject) {
return buildViewColumns(view, subject);
export function buildArchiveColumns(view, subject, displayConfig = {}) {
return buildViewColumns(view, subject, { displayConfig });
}
export function buildArchiveConfiguredView(records, filters, gridView, subject) {
export function buildArchiveConfiguredView(records, filters, gridView, subject, displayConfig = {}) {
if (!gridView) {
return {
filteredRecords: [],
......@@ -83,7 +86,7 @@ export function buildArchiveConfiguredView(records, filters, gridView, subject)
}
const keyword = normalizeText(filters?.keyword).toLowerCase();
const columns = buildArchiveColumns(gridView, subject);
const columns = buildArchiveColumns(gridView, subject, displayConfig);
const searchColumns = buildViewSearchColumns(gridView, subject);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
......
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { getFieldValue } from '@/utils/subjectView';
import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { useCallback, useEffect, useMemo, useState } from 'react';
......@@ -16,6 +19,8 @@ export default function ProblemRectificationArchivePage() {
const [records, setRecords] = useState([]);
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filters, setFilters] = useState({
......@@ -35,10 +40,14 @@ export default function ProblemRectificationArchivePage() {
setRecords(resources.records || []);
setSubject(resources.subject || null);
setViews(resources.views || []);
setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
setRecords([]);
setSubject(null);
setViews([]);
setTableDisplayConfig({});
setTableDisplayConfigError('');
setError(requestError?.message || '归档批次列表加载失败,请稍后重试。');
} finally {
setLoading(false);
......@@ -68,8 +77,8 @@ export default function ProblemRectificationArchivePage() {
);
const configuredView = useMemo(
() => buildArchiveConfiguredView(records, filters, activeGridView, subject),
[records, filters, activeGridView, subject],
() => buildArchiveConfiguredView(records, filters, activeGridView, subject, tableDisplayConfig),
[records, filters, activeGridView, subject, tableDisplayConfig],
);
const filteredRecords = configuredView.filteredRecords || [];
......@@ -163,6 +172,7 @@ export default function ProblemRectificationArchivePage() {
),
}}
states={{
warning: tableDisplayConfigError,
loading,
error,
loadingTitle: '正在加载归档批次',
......@@ -172,6 +182,9 @@ export default function ProblemRectificationArchivePage() {
}}
renderCell={({ record, column }) => {
if (column.field === 'metadata.reference_records') {
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue column={column} value={getFieldValue(record.raw || record, column.field)} />;
}
return (
<span className="tabular-nums">
{record.archiveCount || 0} 条明细
......
......@@ -16,6 +16,8 @@ import {
} from '@/components/TableActionButton';
import XinyuanModal from '@/components/XinyuanModal';
import RecordWorkbench from '@/components/record-workbench';
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import {
batchRemoveRecords,
......@@ -623,6 +625,10 @@ function FieldValuePreview({ column, record }) {
return <div className="font-medium">{record.title || '(无标题)'}</div>;
}
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={value} column={column} />;
}
if (fieldName === 'rectification_progress') {
const text = formatFieldValue(value, column.fieldDef) || '未填写';
return <StatusPill text={text} tone={resolveProgressTone(text)} />;
......@@ -786,6 +792,8 @@ export default function ProblemRectificationListPage() {
const [records, setRecords] = useState([]);
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState('');
const [loading, setLoading] = useState(true);
const [recordsLoading, setRecordsLoading] = useState(false);
const [error, setError] = useState('');
......@@ -837,10 +845,14 @@ export default function ProblemRectificationListPage() {
const resources = await loadProblemRectificationListResources();
setSubject(resources.subject || null);
setViews(resources.views || []);
setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
setRecords([]);
setSubject(null);
setViews([]);
setTableDisplayConfig({});
setTableDisplayConfigError('');
setError(requestError?.message || '问题整改列表加载失败,请稍后重试。');
} finally {
setLoading(false);
......@@ -1058,8 +1070,8 @@ export default function ProblemRectificationListPage() {
}, [reloadList]);
const configuredView = useMemo(
() => buildProblemRectificationConfiguredListView(records, {}, activeGridView, subject),
[records, activeGridView, subject],
() => buildProblemRectificationConfiguredListView(records, {}, activeGridView, subject, tableDisplayConfig),
[records, activeGridView, subject, tableDisplayConfig],
);
const dynamicFilterSchema = useMemo(
() =>
......@@ -1947,6 +1959,7 @@ export default function ProblemRectificationListPage() {
states={{
loading: loading || recordsLoading,
error,
warning: tableDisplayConfigError,
success: '',
loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
......
......@@ -280,6 +280,65 @@
---
### 5.10 表格字段显示配置
入口为 streams-admin 的应用布局配置或 Subject 的 Grid 视图配置,作用于问题清单、归档批次和共享 RecordWorkbench 表格中的引用、选择、子对象及 form 字段。页面加载应用及视图配置后按列渲染,切换视图时使用该视图的配置。
- 应用默认值读取 `layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`,兼容 streams-admin 的 `displayConfig.maxTagCount`。
- 视图默认值读取 `settings.displayConfig`,同名属性覆盖应用默认值;`settings.columns[]` 中的单列 `displayConfig` 再覆盖视图默认值,列上的 `displayMode`、`maxTagCount`、`titleField` 优先级最高。
- `displayMode: "labels"` 显示已选选项或关联标题;选项文案来自字段 `settings.options`,仅展示当前记录实际选中的值。
- `displayMode: "count"` 显示有效条目数量,空值显示 `0`,单值显示 `1`。数量可通过鼠标悬停或键盘聚焦查看完整标题;空值不产生提示。计数只基于该字段已有数据,不额外查询关联记录总数。
- `displayMode: "auto"` 在条目数达到 `maxTagCount` 时显示数量,否则显示标题或选项。只配置 `maxTagCount` 时自动启用此模式,判断使用 `>=`,与 streams-admin 一致。显式 `auto` 未配置合法阈值时使用 `3`;阈值为 `0` 时所有非空字段显示数量。
- 子对象标签优先使用 `titleField` 指定的子字段,例如 `responsible_department`;也支持 `metadata` 下的子字段。未配置或值为空时使用已有 `title/name/_id`,仍无标题时展示“第 N 条”,不猜测业务字段。form 对象为 `{form,data}` 时按 `data` 中的条目计数。
- 未配置显示方式或阈值时保留现有单元格行为,包括归档批次的“X 条明细”;非法显示方式或阈值不修改记录。普通文本、数字、日期、布尔、附件和人员字段不受这项集合显示配置影响。
- 应用配置读取失败时显示警告并使用视图配置;列表数据和详情入口仍可使用。重新加载页面会重试读取配置。配置不会改变筛选条件、排序、分页、审批状态、详情编辑、关联 ID 或 Record 的 `metadata`。
应用配置示例(`problem_rectification_new` 应用):
```json
{
"layouts": {
"default": {
"subjects": {
"problem_rectification": {
"gridRecordConfig": {
"displayConfig": { "maxTagCount": 3 }
}
},
"issue_rectification_archive": {
"gridRecordConfig": {
"displayConfig": { "displayMode": "count" }
}
}
}
}
}
}
```
Grid 视图配置示例(列举的是配置片段,修改时应保留完整原列列表):
```json
{
"settings": {
"displayConfig": { "displayMode": "auto", "maxTagCount": 3 },
"columns": [
{ "field": "metadata.responsible_department", "displayMode": "labels" },
{
"field": "metadata.department_rectification_items",
"displayMode": "count",
"titleField": "responsible_department"
},
{
"field": "metadata.problem_rectification_delay_request_items",
"displayMode": "labels",
"titleField": "responsible_department"
}
]
}
}
```
## 6. 流程角色
- 发起人:通常为问题整改列表操作人,负责发起填报和最终审核;可编辑本次推送关联的父问题记录以及全部部门整改明细业务字段
......
......@@ -11,11 +11,12 @@ import {
DEPARTMENT_GROUPS,
} from './problemRectificationDashboardService';
import { calculateProblemSeverityLevel } from './problemSeverity';
import { loadApplicationTableDisplayConfig } from '@/services/tableFieldDisplayService';
import { formatTableFieldValue } from '@/utils/tableFieldDisplay';
import {
buildViewColumns,
checkViewCondition,
emptyValueForField,
formatFieldValue,
getFieldNameFromPath,
getFieldValue,
getSelectOptions,
......@@ -1262,9 +1263,10 @@ export async function loadProblemRectificationListData({
}
export async function loadProblemRectificationListResources() {
const [subject, viewSummaries] = await Promise.all([
const [subject, viewSummaries, tableDisplayConfig] = await Promise.all([
loadSubject(PROBLEM_RECTIFICATION_SUBJECT),
getSubjectView(PROBLEM_RECTIFICATION_SUBJECT),
loadApplicationTableDisplayConfig(PROBLEM_RECTIFICATION_SUBJECT),
]);
const views = await Promise.all(
toArray(viewSummaries).map(async (view) => {
......@@ -1291,6 +1293,7 @@ export async function loadProblemRectificationListResources() {
return {
subject,
views,
...tableDisplayConfig,
};
}
......@@ -1326,8 +1329,8 @@ export function buildGridViews(views = []) {
return isViewAccessible(fallbackView) ? [fallbackView] : [];
}
export function buildViewColumnsForList(view, subject) {
return buildViewColumns(view, subject);
export function buildViewColumnsForList(view, subject, displayConfig = {}) {
return buildViewColumns(view, subject, { displayConfig });
}
export function getRecordCellText(record, column) {
......@@ -1338,10 +1341,10 @@ export function getRecordCellText(record, column) {
return formatDateText(value) || '-';
}
return formatFieldValue(value, column.fieldDef) || '-';
return formatTableFieldValue(value, column) || '-';
}
export function buildProblemRectificationConfiguredListView(records, filters, gridView, subject) {
export function buildProblemRectificationConfiguredListView(records, filters, gridView, subject, displayConfig = {}) {
if (!gridView) {
return {
filteredRecords: [],
......@@ -1357,7 +1360,7 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
};
}
const columns = buildViewColumnsForList(gridView, subject);
const columns = buildViewColumnsForList(gridView, subject, displayConfig);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
......
......@@ -15,6 +15,8 @@ import {
toArray,
} from '@/utils/subjectView';
import { loadApplicationTableDisplayConfig } from '@/services/tableFieldDisplayService';
const QUERY_PAGE_SIZE = 1000;
const DEFAULT_RECORD_SORT = '-updated_at -created_at';
const SUBJECT_WORKBENCH_RESOURCE_CACHE = new Map();
......@@ -317,8 +319,9 @@ function loadSubjectWorkbenchConfiguration(subjectName) {
configurationPromise = Promise.all([
loadSubject(cacheKey),
getSubjectView(cacheKey),
loadApplicationTableDisplayConfig(cacheKey),
])
.then(([subjectResponse, viewResponse]) => {
.then(([subjectResponse, viewResponse, tableDisplayConfig]) => {
const subject = subjectResponse?.data || subjectResponse?.subject || subjectResponse;
const viewCandidates = [
viewResponse,
......@@ -343,7 +346,7 @@ function loadSubjectWorkbenchConfiguration(subjectName) {
} catch {
return view;
}
})).then((views) => ({ subject, views }));
})).then((views) => ({ subject, views, ...tableDisplayConfig }));
})
.catch((error) => {
// Do not keep a rejected promise: a later reload should be able to retry.
......@@ -371,7 +374,7 @@ export async function loadSubjectWorkbenchResources(
query = {},
{ viewName = '', filters = {}, page = 0, pageSize = 20, hiddenViewNames = [] } = {},
) {
const { subject, views } = await loadSubjectWorkbenchConfiguration(subjectName);
const { subject, views, displayConfig, displayConfigError } = await loadSubjectWorkbenchConfiguration(subjectName);
const accessibleGridViews = buildGridViews(views || [], hiddenViewNames);
const activeView = accessibleGridViews.find((view) => view.name === viewName)
|| accessibleGridViews[0]
......@@ -393,6 +396,8 @@ export async function loadSubjectWorkbenchResources(
totalCount: pageResult.totalCount,
subject,
views: views || [],
displayConfig,
displayConfigError,
};
}
......@@ -417,10 +422,10 @@ export function filterWorkbenchViews(views = [], hiddenViewNames = []) {
return buildGridViews(views, hiddenViewNames);
}
export function buildConfiguredWorkbenchView(records, _filters, view, subject) {
export function buildConfiguredWorkbenchView(records, _filters, view, subject, displayConfig = {}) {
if (!view) return { columns: [], filteredRecords: [] };
const columns = buildViewColumns(view, subject);
const columns = buildViewColumns(view, subject, { displayConfig });
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
......
......@@ -93,3 +93,16 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
引用并要求至少两条可用版本。
- 引用记录缺失、历史版本不足、导入文件为空或目标公司/模版不存在时,在写入前提示用户
检查数据后重试;写入阶段的接口失败会保留服务端错误,避免静默掩盖部分成功结果。
## 6. 表格字段数量与标题显示
配置入口为 streams-admin 应用布局或 Subject 的 Grid 视图。基金产品、投监表模版、投监表指标、托管人及管理人、关联关系和关联方列表通过共享工作台读取配置,切换视图后使用当前视图的显示规则。
- 应用默认值通过 `loadApplication(APPLICATION_NAME)` 读取 `layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`。使用项目现有应用标识,不单独硬编码风险管理应用名。
- 属性覆盖顺序为应用 `displayConfig` → 视图 `settings.displayConfig` → 列 `displayConfig` → 列直接设置的 `displayMode`、`maxTagCount`、`titleField`。
- `labels` 展示关联标题或已选选项文案;选项来自字段 `settings.options`。`count` 展示有效条目数,空值为 `0`,单值为 `1`;悬停或键盘聚焦可查看完整标题,空值不显示提示。
- `auto` 在有效条目数量大于等于 `maxTagCount` 时显示数量,否则显示标题。仅设置阈值时自动启用 `auto`;显式 `auto` 的默认阈值为 `3`;阈值为 `0` 时所有非空值显示数量。
- 引用、选择、子对象与 form 字段适用上述规则。子对象优先读取 `titleField`(支持 metadata 子字段),然后使用已有 `title/name/_id`,无标题时显示“第 N 条”;`{form,data}` 按 `data` 条目计数。空字符串、null 和 undefined 不计数,数字 `0` 保留。
- 例如基金产品的 `metadata.contract_terms`、投监表模版的 `metadata.associated_indicators` 可配置 `displayMode: "count"`;基金产品的 `metadata.investment_scope` 可配置 `displayMode: "labels"`。字段仍以 Subject 定义为准。
- 未配置或配置非法时保留原展示;普通文本、数字、日期、布尔、人员和附件不受影响。首列仍可点击打开详情,行操作入口保持可用。计数只使用当前字段已有数据,不额外查询关联总数,不修改 metadata、筛选、排序、分页、导出或审批规则。
- 加载配置期间沿用列表 loading;应用配置失败时显示警告并继续使用视图配置和列表数据。共享工作台按 Subject 缓存配置,刷新页面重新读取;记录查询失败仍显示列表错误。
......@@ -44,6 +44,14 @@
- `404` `{"code":404,"message":"record '<name>' not found"}`
- `400` 业务校验失败(缺少必填字段、主键冲突、类型不匹配等)
### 1.2 表格显示配置的关联读取接口
风险管理共享工作台、问题清单与归档批次通过已有 `DataService.loadApplication(APPLICATION_NAME)` 调用 `GET /application/:name`,读取应用的 `layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`,路径与 streams-admin 的 `useAppLayout().getSubjectConfig()` / `RecordGridView` 一致。
视图继续使用 `GET /subject/:subjectName/view` 与 `GET /subject/:subjectName/view/:viewName`,分别对应 `getSubjectView` / `getSubjectViewDetail`。视图级显示配置位于 `settings.displayConfig`,单列覆盖位于 `settings.columns[]`。支持 `displayMode: labels | count | auto`、`maxTagCount`(非负整数)和子对象列 `titleField`(子字段路径)。
这里只读取配置,不更新应用、视图、Subject 或 Record。应用配置请求失败时仍加载记录,提示配置读取失败并使用视图配置;记录查询和写入协议不变。完整优先级和示例见 `src/pages/problem_rectification/problem-rectification.business-flow.md` 的“5.10 表格字段显示配置”及 `src/pages/risk_management/risk-management.business-flow.md` 的“6. 表格字段数量与标题显示”。
## 2. Record 核心模型字段
不同 `subject` 通过 `metadata` 扩展业务字段。
......
import { APPLICATION_NAME } from '@/config/application';
import { loadApplication } from '@/services/DataService';
export async function loadApplicationTableDisplayConfig(subjectName) {
try {
const application = await loadApplication(APPLICATION_NAME);
return {
displayConfig: application?.layouts?.default?.subjects?.[subjectName]?.gridRecordConfig?.displayConfig || {},
displayConfigError: '',
};
} catch (error) {
return {
displayConfig: {},
displayConfigError: `应用表格显示配置读取失败,暂使用视图默认显示:${error?.message || '请稍后重试'}`,
};
}
}
......@@ -297,9 +297,10 @@ export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
export function buildViewColumns(view, subject, { includeHidden = false, displayConfig = {} } = {}) {
const configuredColumns = view?.settings?.columns || [];
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
const viewDisplayConfig = { ...displayConfig, ...view?.settings?.displayConfig };
if (configuredColumns.length > 0) {
return configuredColumns
......@@ -312,6 +313,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
const fieldDef = getFieldDefinition(column.field, subject);
return {
...column,
displayConfig: { ...viewDisplayConfig, ...column.displayConfig },
width: column.width || 180,
label: getConfiguredColumnLabel(column, fieldDef),
fieldDef: fieldDef || { name: column.field, label: column.field, type: 'text' },
......@@ -323,6 +325,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
if (!hideTitleColumn) {
defaultColumns.push({
field: 'title',
displayConfig: viewDisplayConfig,
width: 180,
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
......@@ -332,6 +335,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
toArray(subject?.fields).forEach((field) => {
defaultColumns.push({
field: `metadata.${field.name}`,
displayConfig: viewDisplayConfig,
width: 180,
label: field.label || field.name,
fieldDef: field,
......
import {
formatFieldValue,
getFieldValue,
getSelectOptions,
isReferenceFieldType,
normalizeRecordLabel,
normalizeText,
toArray,
} from './subjectView';
export const TABLE_FIELD_DISPLAY_MODES = ['labels', 'count', 'auto'];
export const DEFAULT_TABLE_MAX_TAG_COUNT = 3;
export function isTableFieldDisplayConfigurable(column) {
const type = column?.fieldDef?.type;
return isReferenceFieldType(type) || ['select', 'subject', 'form'].includes(type);
}
export function getTableFieldDisplayConfig(column) {
const config = column?.displayConfig || {};
const configuredMode = column?.displayMode ?? config.displayMode;
const configuredThreshold = column?.maxTagCount ?? config.maxTagCount;
const threshold = Number(configuredThreshold);
const hasThreshold = ['number', 'string'].includes(typeof configuredThreshold) &&
normalizeText(configuredThreshold) !== '' && Number.isInteger(threshold) && threshold >= 0;
const hasMode = TABLE_FIELD_DISPLAY_MODES.includes(configuredMode);
return {
isConfigured: isTableFieldDisplayConfigurable(column) && (hasMode || hasThreshold),
displayMode: hasMode ? configuredMode : hasThreshold ? 'auto' : 'labels',
maxTagCount: hasThreshold ? threshold : DEFAULT_TABLE_MAX_TAG_COUNT,
titleField: normalizeText(column?.titleField ?? config.titleField),
};
}
function getDisplayItems(value, fieldDef) {
const items = fieldDef?.type === 'form' && value && !Array.isArray(value) && Array.isArray(value.data)
? value.data
: toArray(value);
return items.filter((item) =>
item !== null && item !== undefined && !(typeof item === 'string' && !item.trim()),
);
}
export function getTableFieldDisplay(value, column) {
const config = getTableFieldDisplayConfig(column);
const items = getDisplayItems(value, column?.fieldDef);
const type = column?.fieldDef?.type;
const options = type === 'select' ? getSelectOptions(column.fieldDef) : [];
const labels = items.map((item, index) => {
if (type === 'select') {
const optionValue = item && typeof item === 'object' ? item.value : item;
const option = options.find((candidate) => String(candidate.value) === String(optionValue));
return normalizeText(option?.label ?? optionValue);
}
if (['subject', 'form'].includes(type)) {
const titleValue = config.titleField
? getFieldValue(item, config.titleField) ?? getFieldValue(item?.metadata, config.titleField)
: null;
return formatFieldValue(titleValue) || normalizeRecordLabel(item) || `第 ${index + 1} 条`;
}
if (type === 'tag') return normalizeText(item?.label) || normalizeRecordLabel(item);
return normalizeRecordLabel(item);
}).filter(Boolean);
const showCount = config.displayMode === 'count' ||
(config.displayMode === 'auto' && items.length > 0 && items.length >= config.maxTagCount);
return {
...config,
count: items.length,
labels,
showCount,
text: showCount ? String(items.length) : labels.join('、'),
};
}
export function formatTableFieldValue(value, column) {
const display = getTableFieldDisplay(value, column);
return display.isConfigured ? display.text : formatFieldValue(value, column?.fieldDef);
}
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import test from 'node:test';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const sourceRoot = new URL('../src/', import.meta.url).pathname;
function loadModule(entry, dataService = {}) {
const modules = new Map();
const load = (path) => {
if (modules.has(path)) return modules.get(path);
const exports = {};
modules.set(path, exports);
const { outputText } = ts.transpileModule(readFileSync(path, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
});
const localRequire = (specifier) => {
if (specifier === './DataService' || specifier === '@/services/DataService') return dataService;
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) return require(specifier);
const dependencyPath = specifier.startsWith('@/')
? resolve(sourceRoot, specifier.slice(2))
: resolve(dirname(path), specifier);
return load(`${dependencyPath}.js`);
};
new Function('require', 'exports', outputText)(localRequire, exports);
return exports;
};
return load(resolve(sourceRoot, entry));
}
const { getTableFieldDisplay, getTableFieldDisplayConfig, formatTableFieldValue } = loadModule('utils/tableFieldDisplay.js');
const { buildViewColumns } = loadModule('utils/subjectView.js');
const column = (type, config = {}) => ({ field: 'metadata.values', fieldDef: { type }, ...config });
test('count mode counts selected values, including zero, and excludes empty entries', () => {
const config = column('select', { displayMode: 'count' });
assert.equal(formatTableFieldValue([0, '选中', null, undefined, '', ' '], config), '2');
for (const value of [null, undefined, '', []]) assert.equal(formatTableFieldValue(value, config), '0');
assert.equal(formatTableFieldValue(0, config), '1');
assert.equal(formatTableFieldValue({ _id: 'one', title: '关联标题' }, column('reference', { displayMode: 'count' })), '1');
});
test('labels show selected option labels and populated record titles', () => {
const config = column('select', { displayMode: 'labels' });
config.fieldDef.settings = { options: [{ value: 0, label: '零号' }, { value: 'a', label: '选项 A' }, { value: 'b', label: '未选项' }] };
assert.equal(formatTableFieldValue(['0', 'a'], config), '零号、选项 A');
assert.equal(formatTableFieldValue([{ _id: 'id-1', title: '记录 A' }, { _id: 'id-2', title: '记录 B' }], column('reference', { displayMode: 'labels' })), '记录 A、记录 B');
assert.equal(formatTableFieldValue([{ name: 'risk', label: '风险标签' }], column('tag', { displayMode: 'labels' })), '风险标签');
});
test('maxTagCount alone matches streams-admin at the exact threshold', () => {
const config = column('reference', { displayConfig: { maxTagCount: 2 } });
assert.equal(formatTableFieldValue([{ title: 'A' }], config), 'A');
assert.equal(formatTableFieldValue([{ title: 'A' }, { title: 'B' }], config), '2');
assert.equal(formatTableFieldValue([], config), '');
assert.equal(formatTableFieldValue(['A'], column('reference', { displayMode: 'auto', maxTagCount: 0 })), '1');
assert.equal(getTableFieldDisplayConfig(column('reference', { displayMode: 'auto' })).maxTagCount, 3);
});
test('embedded subjects and forms use configured child labels without changing data', () => {
const value = [{ responsible_department: '审计部' }, { metadata: { responsible_department: '风险管理部' } }];
const original = JSON.stringify(value);
const config = column('subject', { displayMode: 'labels', titleField: 'responsible_department' });
assert.equal(formatTableFieldValue(value, config), '审计部、风险管理部');
assert.equal(getTableFieldDisplay(value, { ...config, displayMode: 'count' }).count, 2);
assert.equal(formatTableFieldValue([{}, {}], config), '第 1 条、第 2 条');
assert.equal(formatTableFieldValue({ form: { title: '表单' }, data: value }, column('form', { displayMode: 'count' })), '2');
assert.equal(JSON.stringify(value), original);
});
test('application defaults, view settings, and column overrides keep independent modes', () => {
const subject = { fields: [{ name: 'values', type: 'select' }] };
const view = { settings: {
displayConfig: { maxTagCount: 2 },
columns: [{ field: 'metadata.values', displayMode: 'labels' }],
} };
const original = JSON.stringify(view);
const [config] = buildViewColumns(view, subject, { displayConfig: { displayMode: 'count', maxTagCount: 5 } });
assert.equal(getTableFieldDisplayConfig(config).maxTagCount, 2);
assert.equal(formatTableFieldValue(['A', 'B'], config), 'A、B');
const [nestedConfig] = buildViewColumns({ settings: {
displayConfig: { displayMode: 'count', maxTagCount: 5 },
columns: [{ field: 'metadata.values', displayConfig: { displayMode: 'auto', maxTagCount: 2 } }],
} }, subject);
assert.equal(formatTableFieldValue(['A', 'B'], nestedConfig), '2');
const fallback = buildViewColumns({ settings: {} }, subject, { displayConfig: { displayMode: 'count' } });
assert.equal(formatTableFieldValue(['A', 'B'], fallback.find((item) => item.field === 'metadata.values')), '2');
assert.equal(JSON.stringify(view), original);
});
test('missing or invalid config preserves current behavior and does not affect scalar fields', () => {
const value = [{ responsible_department: '审计部' }];
assert.equal(formatTableFieldValue(value, column('subject')), JSON.stringify(value));
assert.equal(formatTableFieldValue('A', column('select', { displayMode: 'invalid', maxTagCount: -1 })), 'A');
for (const type of ['text', 'number', 'date', 'boolean', 'user', 'file', 'object']) {
assert.equal(getTableFieldDisplayConfig(column(type, { displayMode: 'count' })).isConfigured, false);
}
for (const maxTagCount of ['invalid', -1, 1.5, null, '', ' ', false, true, []]) {
assert.equal(getTableFieldDisplayConfig(column('reference', { displayConfig: { maxTagCount } })).isConfigured, false);
}
});
test('application config is read from the same layout path as streams-admin', async () => {
const requests = [];
const service = loadModule('services/tableFieldDisplayService.js', {
loadApplication: async (name) => {
requests.push(name);
return { layouts: { default: { subjects: { problem_rectification: {
gridRecordConfig: { displayConfig: { maxTagCount: 2 } },
} } } } };
},
});
assert.deepEqual(await service.loadApplicationTableDisplayConfig('problem_rectification'), {
displayConfig: { maxTagCount: 2 }, displayConfigError: '',
});
assert.deepEqual(requests, ['problem_rectification_new']);
assert.deepEqual(await service.loadApplicationTableDisplayConfig('issue_rectification_archive'), {
displayConfig: {}, displayConfigError: '',
});
});
test('unavailable application config returns a warning and usable view fallback', async () => {
const service = loadModule('services/tableFieldDisplayService.js', {
loadApplication: async () => { throw new Error('无权限'); },
});
const result = await service.loadApplicationTableDisplayConfig('problem_rectification');
assert.deepEqual(result.displayConfig, {});
assert.match(result.displayConfigError, /暂使用视图默认显示.*无权限/);
});
test('risk workbench carries application defaults through resources and view changes', async () => {
const applicationRequests = [];
const service = loadModule('pages/project_manager_xy/SubjectWorkbenchService.js', {
loadSubject: async () => ({ fields: [{ name: 'contract_terms', type: 'reference', multiple: true }] }),
getSubjectView: async () => [],
loadApplication: async (name) => {
applicationRequests.push(name);
return { layouts: { default: { subjects: { fund_product: {
gridRecordConfig: { displayConfig: { maxTagCount: 2 } },
} } } } };
},
});
const resources = await service.loadSubjectWorkbenchResources('fund_product');
assert.deepEqual(resources.displayConfig, { maxTagCount: 2 });
assert.equal(resources.displayConfigError, '');
const records = [{ raw: { metadata: { contract_terms: [{ title: 'A' }, { title: 'B' }] } } }];
for (const [displayMode, expected] of [['auto', '2'], ['labels', 'A、B']]) {
const view = { settings: { columns: [{ field: 'metadata.contract_terms', displayMode }] } };
const result = service.buildConfiguredWorkbenchView(records, {}, view, resources.subject, resources.displayConfig);
assert.equal(formatTableFieldValue(records[0].raw.metadata.contract_terms, result.columns[0]), expected);
assert.equal(result.filteredRecords.length, 1);
}
await service.loadSubjectWorkbenchResources('fund_product');
assert.equal(applicationRequests.length, 1);
});
test('risk workbench preserves subject resources when application configuration fails', async () => {
const service = loadModule('pages/project_manager_xy/SubjectWorkbenchService.js', {
loadSubject: async () => ({ name: 'fund_product', fields: [] }),
getSubjectView: async () => [],
loadApplication: async () => { throw new Error('配置不可用'); },
});
const resources = await service.loadSubjectWorkbenchResources('fund_product');
assert.equal(resources.subject.name, 'fund_product');
assert.deepEqual(resources.displayConfig, {});
assert.match(resources.displayConfigError, /配置不可用/);
});
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