Commit fb868f94 authored by drigle's avatar drigle

feat: configure table field count and label display

parent 2ebc3759
import { Chip, Tooltip } from '@heroui/react';
import { DataTableText } from './DataTable';
import { getTableFieldDisplay } from '@/utils/tableFieldDisplay';
export default function TableFieldValue({ value, column }) {
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>
<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>
) : count;
}
return <DataTableText text={display.text || '-'} maxLines={2} showTooltip />;
}
import TableFieldValue from '@/components/TableFieldValue';
import { formatTableFieldValue, getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { ChevronDown, Close, Eye, More, Reload, Search } from '@/components/AppIcons';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import FilterSelect from '@/components/FilterSelect';
......@@ -41,7 +43,7 @@ function getFilterPlaceholder(field, fieldType) {
function estimatePrimaryColumnWidth(records, column) {
const values = records.map((record) => {
const value = formatFieldValue(getFieldValue(record?.raw || record, column.field), column.fieldDef) || '-';
const value = formatTableFieldValue(getFieldValue(record?.raw || record, column.field), column) || '-';
return String(value);
});
const longestUnits = values.reduce((maximum, value) => {
......@@ -256,7 +258,10 @@ export default function RecordWorkbench({
cell: (record) => {
const custom = renderCell?.({ record, column, value: getFieldValue(record?.raw || record, column.field), openDetail });
if (custom !== undefined && custom !== null) return custom;
const value = formatFieldValue(getFieldValue(record?.raw || record, column.field), column.fieldDef) || '-';
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={getFieldValue(record?.raw || record, column.field)} column={column} />;
}
const value = formatTableFieldValue(getFieldValue(record?.raw || record, column.field), column) || '-';
if (index === 0) {
return (
<Tooltip delay={0}>
......@@ -464,6 +469,7 @@ export default function RecordWorkbench({
) : null}
</header>
) : null}
{states.warning ? <Alert status="warning"><Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content></Alert> : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="rc-list-data-stack rc-data-table-sticky relative min-w-0" ref={gridWrapRef}>
{accessDenied ? (
......
......@@ -81,6 +81,8 @@ export default function SubjectWorkbench() {
const gridConfig = subjectConfig.gridRecordConfig || {};
const paginationEnabled = gridConfig.pagination !== false;
const [subject, setSubject] = useState(null);
const [displayConfig, setDisplayConfig] = useState({});
const [displayConfigError, setDisplayConfigError] = useState('');
const [views, setViews] = useState(EMPTY_ARRAY);
const [activeViewName, setActiveViewName] = useState('');
const [records, setRecords] = useState(EMPTY_ARRAY);
......@@ -105,8 +107,8 @@ export default function SubjectWorkbench() {
);
const hasAccessibleView = views.length > 0;
const columns = useMemo(
() => buildSubjectColumns(activeView, subject),
[activeView, subject],
() => buildSubjectColumns(activeView, subject, displayConfig),
[activeView, subject, displayConfig],
);
const filterSchema = useMemo(() => buildSubjectFilterSchema(activeView, subject), [activeView, subject]);
......@@ -118,6 +120,8 @@ export default function SubjectWorkbench() {
let cancelled = false;
setLoading(true);
setError('');
setDisplayConfig({});
setDisplayConfigError('');
setRecords(EMPTY_ARRAY);
setSelectedIds(EMPTY_ARRAY);
setFilters({});
......@@ -128,6 +132,8 @@ export default function SubjectWorkbench() {
.then((resources) => {
if (cancelled) return;
setSubject(resources.subject);
setDisplayConfig(resources.displayConfig);
setDisplayConfigError(resources.displayConfigError);
setViews(resources.views);
setActiveViewName(resources.views[0]?.name || '');
})
......@@ -345,6 +351,7 @@ export default function SubjectWorkbench() {
states={{
loading,
error,
warning: displayConfigError,
loadingTitle: '正在加载业务数据',
loadingDescription: '正在读取对象结构、视图配置和记录数据。',
}}
......
......@@ -21,7 +21,7 @@
## 2. 列表与视图流程
1. 进入页面后并行调用 `GET /subject/:name` 和 `GET /subject/:name/view`;打开详情后再按 View 名调用 `GET /subject/:subjectName/view/:viewName` 获取完整 Detail 配置。
1. 进入页面后并行调用 `GET /subject/:name`、`GET /subject/:name/view` 和 `GET /application/:name`;打开详情后再按 View 名调用 `GET /subject/:subjectName/view/:viewName` 获取完整 Detail 配置。
2. 只展示当前用户有权访问的 `grid` 视图;授权只读取 `user.applications[]` 中 `name = integrity_practice_management` 的项目。系统管理员、系统账号和该应用“管理”角色可旁路,但不能旁路 `disableColumns`。配置包含 `filterView` 且非空时,只展示指定视图。
3. 切换视图后,根据视图的 `settings.restrictions`、`settings.filters.conditions`、列排序配置调用 `POST /record/query`;筛选控件默认由当前 View 的可见字段自动生成。若 View 配置了 `settings.filters.fields`,则仅按该字段数组的顺序生成筛选控件,支持 `metadata.field`、`field` 字符串及 `{ field }`、`{ path }`、`{ name }` 对象项,显式配置可引用隐藏列;空数组表示该视图不展示筛选控件,无效字段会被忽略。文本字段使用远程模糊查询,Select/日期字段使用字段配置值。
4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。
......@@ -46,6 +46,65 @@
- 多值集合、否定条件或同一字段互相冲突时无法确定唯一值,禁止创建并提示“请先调整视图配置”。
- 新建请求使用 `POST /record`,所有业务字段放在 `metadata`;编辑已有记录只提交用户修改字段,不使用 Grid View 限制覆盖原值。
### 2.2 表格字段显示配置
入口为 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` 指定的子字段,例如 `actual_filling_department`;也支持 `metadata` 下的子字段。未配置或值为空时使用已有 `title/name/_id`,仍无标题时展示“第 N 条”,不猜测业务字段。form 对象为 `{form,data}` 时按 `data` 中的条目计数。
- 未配置显示方式或阈值时保留现有单元格行为;非法显示方式或阈值不修改记录。普通文本、数字、日期、布尔、附件和人员字段不受这项集合显示配置影响。
- 应用配置读取失败时显示警告并使用视图配置;列表数据和详情入口仍可使用。重新加载页面会重试读取配置。配置不会改变筛选条件、排序、分页、审批状态、详情编辑、关联 ID 或 Record 的 `metadata`。
应用配置示例(`integrity_practice_management` 应用):
```json
{
"layouts": {
"default": {
"subjects": {
"self_check_the_results_of_the_working_draft": {
"gridRecordConfig": {
"displayConfig": { "maxTagCount": 3 }
}
},
"self_inspection_annual_submission_materials": {
"gridRecordConfig": {
"displayConfig": { "displayMode": "count" }
}
}
}
}
}
}
```
Grid 视图配置示例(列举的是配置片段,修改时应保留完整原列列表):
```json
{
"settings": {
"displayConfig": { "displayMode": "auto", "maxTagCount": 3 },
"columns": [
{ "field": "metadata.actual_filling_department", "displayMode": "labels" },
{
"field": "metadata.project_outcome",
"displayMode": "count",
"titleField": "title"
},
{
"field": "metadata.reference_integrity_practice_report",
"displayMode": "labels",
"titleField": "title"
}
]
}
}
```
## 3. 记录选择与动作规则
- 行动作来自 `gridRecordConfig.menuConfig`,顶部动作来自 `gridRecordConfig.tabBarRightSlot`。
......
import { loadApplicationTableDisplayConfig } from '@/services/tableFieldDisplayService';
import {
batchRemoveRecords,
batchUpdateRecords,
......@@ -140,9 +141,10 @@ function buildViewSort(view) {
}
export async function loadSubjectResources(subjectName, config = {}) {
const [subject, views] = await Promise.all([
const [subject, views, tableDisplayConfig] = await Promise.all([
loadSubject(subjectName),
getSubjectView(subjectName),
loadApplicationTableDisplayConfig(subjectName),
]);
const configuredViewNames = config?.gridRecordConfig?.filterView;
const availableViews = toArray(views)
......@@ -158,6 +160,7 @@ export async function loadSubjectResources(subjectName, config = {}) {
return {
subject,
views: availableViews,
...tableDisplayConfig,
};
}
......@@ -194,8 +197,8 @@ export async function loadSubjectRecords({
};
}
export function buildSubjectColumns(view, subject) {
return buildViewColumns(view, subject);
export function buildSubjectColumns(view, subject, displayConfig = {}) {
return buildViewColumns(view, subject, { displayConfig });
}
export function buildSubjectFilterSchema(view, subject) {
......
......@@ -44,6 +44,14 @@
- `404` `{"code":404,"message":"record '<name>' not found"}`
- `400` 业务校验失败(缺少必填字段、主键冲突、类型不匹配等)
### 1.2 表格显示配置的关联读取接口
廉洁从业管理列表通过已有 `DataService.loadApplication(APPLICATION_NAME)` 调用 `GET /application/:name`(应用名 `integrity_practice_management`),读取 `layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`,与 streams-admin 使用相同路径。
视图继续通过 `GET /subject/:name/view` 读取。视图级配置为 `settings.displayConfig`,单列覆盖为 `settings.columns[]` 中的 `displayConfig` 或直接属性。支持 `displayMode: labels | count | auto`、非负整数 `maxTagCount` 和子字段路径 `titleField`。
这里只读取配置;请求失败时显示警告并使用视图配置,记录加载与写入协议保持不变。优先级及示例见廉洁从业管理流程文档第 2.2 节。
## 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 || '请稍后重试'}`,
};
}
}
......@@ -277,8 +277,9 @@ 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 viewDisplayConfig = { ...displayConfig, ...view?.settings?.displayConfig };
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
if (configuredColumns.length > 0) {
......@@ -290,6 +291,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
const label = getFieldLabel(column.field, fieldDef, column.label);
return {
...column,
displayConfig: { ...viewDisplayConfig, ...column.displayConfig },
width: column.width || 180,
label,
fieldDef: fieldDef || {
......@@ -305,6 +307,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' },
......@@ -315,6 +318,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
const fieldPath = `metadata.${field.name}`;
defaultColumns.push({
field: fieldPath,
displayConfig: viewDisplayConfig,
width: 180,
label: getFieldLabel(fieldPath, field),
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: { self_check_the_results_of_the_working_draft: {
gridRecordConfig: { displayConfig: { maxTagCount: 2 } },
} } } } };
},
});
assert.deepEqual(await service.loadApplicationTableDisplayConfig('self_check_the_results_of_the_working_draft'), {
displayConfig: { maxTagCount: 2 }, displayConfigError: '',
});
assert.deepEqual(requests, ['integrity_practice_management']);
assert.deepEqual(await service.loadApplicationTableDisplayConfig('self_inspection_annual_submission_materials'), {
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('self_check_the_results_of_the_working_draft');
assert.deepEqual(result.displayConfig, {});
assert.match(result.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