Commit 936e5fa0 authored by drigle's avatar drigle

feat: generate remote filters from project views

parent 230b53ca
...@@ -6,19 +6,64 @@ import { ...@@ -6,19 +6,64 @@ import {
cloneRecord, cloneRecord,
} from '@/services/DataService'; } from '@/services/DataService';
import { Copy, Delete, Edit, PlusIcon } from '@/components/AppIcons'; import { Copy, Delete, Edit, PlusIcon } from '@/components/AppIcons';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { getReferenceSubjectName, getSelectOptions, isDateFieldType, isReferenceFieldType } from '@/utils/subjectView';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import DynamicRecordDialog from './DynamicRecordDialog'; import DynamicRecordDialog from './DynamicRecordDialog';
import { import {
buildConfiguredWorkbenchView, buildConfiguredWorkbenchView,
buildGridViews, buildGridViews,
buildViewCountMap,
collectProjectYears,
loadSubjectWorkbenchResources, loadSubjectWorkbenchResources,
} from '@/pages/project_manager_xy/SubjectWorkbenchService'; } from '@/pages/project_manager_xy/SubjectWorkbenchService';
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100]; const PAGE_SIZE_OPTIONS = [20, 50, 100];
const BOOLEAN_FILTER_OPTIONS = [
{ label: '是', value: 'true' },
{ label: '否', value: 'false' },
];
function buildFilterSchema(columns = []) {
return columns.reduce((fields, column) => {
const fieldDef = column?.fieldDef || {};
const fieldType = String(fieldDef.type || 'text').toLowerCase();
const field = column?.field;
if (!field) return fields;
let controlType = null;
let options;
if (fieldType === 'select') {
options = getSelectOptions(fieldDef);
controlType = options.length > 0 ? 'select' : 'text';
} else if (fieldType === 'boolean') {
controlType = 'select';
options = BOOLEAN_FILTER_OPTIONS;
} else if (isDateFieldType(fieldType)) {
controlType = 'date';
} else if (fieldType === 'number') {
controlType = 'number';
} else if (fieldType === 'user') {
controlType = 'user';
} else if (isReferenceFieldType(fieldType)) {
controlType = getReferenceSubjectName(fieldDef) ? 'reference' : 'text';
} else if (['text', 'long_text', 'rich_text'].includes(fieldType)) {
controlType = 'text';
}
if (!controlType) return fields;
fields.push({
key: `field:${field}`,
field,
fieldDef,
label: column.label || field,
type: controlType,
options,
placeholder: `筛选${column.label || field}`,
});
return fields;
}, []);
}
function normalizeActionContext(context, helpers) { function normalizeActionContext(context, helpers) {
return { return {
...context, ...context,
...@@ -52,16 +97,20 @@ export default function SubjectRecordWorkbench({ ...@@ -52,16 +97,20 @@ export default function SubjectRecordWorkbench({
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [activeViewName, setActiveViewName] = useState(''); const [activeViewName, setActiveViewName] = useState('');
const [filters, setFilters] = useState({ keyword: '', status: '', year: '' }); const [filters, setFilters] = useState({});
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [totalRecords, setTotalRecords] = useState(0);
const [recordDialogOpen, setRecordDialogOpen] = useState(false); const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState(null); const [editingRecord, setEditingRecord] = useState(null);
const [deleteTargets, setDeleteTargets] = useState([]); const [deleteTargets, setDeleteTargets] = useState([]);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const requestSequence = useRef(0);
const reload = useCallback(async ({ keepFeedback = false } = {}) => { const reload = useCallback(async ({ keepFeedback = false } = {}) => {
const requestId = requestSequence.current + 1;
requestSequence.current = requestId;
setLoading(true); setLoading(true);
setError(''); setError('');
if (!keepFeedback) { if (!keepFeedback) {
...@@ -69,34 +118,47 @@ export default function SubjectRecordWorkbench({ ...@@ -69,34 +118,47 @@ export default function SubjectRecordWorkbench({
} }
try { try {
const resources = await loadSubjectWorkbenchResources(subjectName, query); const resources = await loadSubjectWorkbenchResources(subjectName, query, {
viewName: activeViewName,
filters,
page,
pageSize,
});
if (requestId !== requestSequence.current) return;
setRecords(resources.records || []); setRecords(resources.records || []);
setTotalRecords(resources.totalCount || 0);
setSubject(resources.subject || null); setSubject(resources.subject || null);
setViews(resources.views || []); setViews(resources.views || []);
} catch (requestError) { } catch (requestError) {
if (requestId !== requestSequence.current) return;
setRecords([]); setRecords([]);
setTotalRecords(0);
setSubject(null); setSubject(null);
setViews([]); setViews([]);
setError(requestError?.message || '列表加载失败,请稍后重试。'); setError(requestError?.message || '列表加载失败,请稍后重试。');
} finally { } finally {
if (requestId === requestSequence.current) {
setLoading(false); setLoading(false);
} }
}, [subjectName, query]); }
}, [activeViewName, filters, page, pageSize, query, subjectName]);
useEffect(() => { useEffect(() => {
reload(); const delay = Object.values(filters).some((value) => String(value || '').trim()) ? 300 : 0;
}, [reload]); const timer = window.setTimeout(() => reload(), delay);
return () => window.clearTimeout(timer);
}, [filters, reload]);
const handleViewChange = useCallback(async (viewName) => { const handleViewChange = useCallback((viewName) => {
if (!viewName || viewName === activeViewName) { if (!viewName || viewName === activeViewName) {
return; return;
} }
setActiveViewName(viewName); setActiveViewName(viewName);
setFilters({});
setSelectedIds([]); setSelectedIds([]);
setPage(0); setPage(0);
await reload({ keepFeedback: true }); }, [activeViewName]);
}, [activeViewName, reload]);
const gridViews = useMemo(() => buildGridViews(views), [views]); const gridViews = useMemo(() => buildGridViews(views), [views]);
...@@ -116,21 +178,17 @@ export default function SubjectRecordWorkbench({ ...@@ -116,21 +178,17 @@ export default function SubjectRecordWorkbench({
); );
const hasAccessibleView = Boolean(activeView); const hasAccessibleView = Boolean(activeView);
const configuredView = useMemo( const configuredView = useMemo(
() => buildConfiguredWorkbenchView(records, filters, activeView, subject), () => buildConfiguredWorkbenchView(records, {}, activeView, subject),
[records, filters, activeView, subject], [records, activeView, subject],
); );
const filteredRecords = configuredView.filteredRecords || []; const filteredRecords = configuredView.filteredRecords || [];
const visibleColumns = useMemo(() => { const visibleColumns = useMemo(() => {
const hiddenFields = new Set(hiddenColumnFields); const hiddenFields = new Set(hiddenColumnFields);
return (configuredView.columns || []).filter((column) => !hiddenFields.has(column.field)); return (configuredView.columns || []).filter((column) => !hiddenFields.has(column.field));
}, [configuredView.columns, hiddenColumnFields]); }, [configuredView.columns, hiddenColumnFields]);
const totalPages = Math.max(1, Math.ceil(filteredRecords.length / pageSize)); const totalPages = Math.max(1, Math.ceil(totalRecords / pageSize));
const safePage = Math.min(page, totalPages - 1); const safePage = Math.min(page, totalPages - 1);
const pageRecords = filteredRecords.slice( const displayRecords = loading ? [] : filteredRecords;
safePage * pageSize,
(safePage + 1) * pageSize,
);
const displayRecords = loading ? [] : pageRecords;
const displaySelectedIds = loading ? [] : selectedIds; const displaySelectedIds = loading ? [] : selectedIds;
const displayPage = loading ? 0 : safePage; const displayPage = loading ? 0 : safePage;
...@@ -145,26 +203,9 @@ export default function SubjectRecordWorkbench({ ...@@ -145,26 +203,9 @@ export default function SubjectRecordWorkbench({
setSelectedIds((current) => current.filter((id) => visibleIds.has(id))); setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [filteredRecords]); }, [filteredRecords]);
const viewCounts = useMemo(
() => buildViewCountMap(records, filters, gridViews, subject),
[records, filters, gridViews, subject],
);
const filterSchema = useMemo(() => { const filterSchema = useMemo(() => {
const fields = [ return buildFilterSchema(visibleColumns);
{ }, [visibleColumns]);
key: 'keyword',
label: '关键词搜索',
type: 'text',
icon: 'search',
placeholder: '请输入关键词',
},
];
const yearOptions = collectProjectYears(records);
if (yearOptions.length > 0) {
fields.push({ key: 'year', label: '项目年度', type: 'select', options: yearOptions });
}
return fields;
}, [records]);
const feedbackHelpers = useMemo(() => ({ const feedbackHelpers = useMemo(() => ({
reload, reload,
...@@ -300,7 +341,7 @@ export default function SubjectRecordWorkbench({ ...@@ -300,7 +341,7 @@ export default function SubjectRecordWorkbench({
<> <>
<RecordWorkbench <RecordWorkbench
summaryItems={summaryItems} summaryItems={summaryItems}
views={gridViews.map((view) => ({ ...view, count: viewCounts[view.name] || 0 }))} views={gridViews}
activeViewName={activeView?.name || ''} activeViewName={activeView?.name || ''}
onViewChange={handleViewChange} onViewChange={handleViewChange}
filters={filters} filters={filters}
...@@ -351,7 +392,7 @@ export default function SubjectRecordWorkbench({ ...@@ -351,7 +392,7 @@ export default function SubjectRecordWorkbench({
page: displayPage, page: displayPage,
pageSize, pageSize,
pageSizeOptions: PAGE_SIZE_OPTIONS, pageSizeOptions: PAGE_SIZE_OPTIONS,
totalRecords: loading ? 0 : filteredRecords.length, totalRecords: loading ? 0 : totalRecords,
totalPages, totalPages,
onPageChange: (nextPage) => { onPageChange: (nextPage) => {
setPage(nextPage); setPage(nextPage);
......
...@@ -24,6 +24,8 @@ import { ...@@ -24,6 +24,8 @@ import {
} from '@/components/AppIcons'; } from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect'; import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker'; import FormDatePicker from '@/components/FormDatePicker';
import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect';
import UserSearchSelect from '@/components/UserSearchSelect';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import { import {
Alert, Alert,
...@@ -970,19 +972,35 @@ export default function RecordWorkbench({ ...@@ -970,19 +972,35 @@ export default function RecordWorkbench({
placeholder={field.placeholder || `请选择${field.label}`} placeholder={field.placeholder || `请选择${field.label}`}
variant={filterFieldVariant} variant={filterFieldVariant}
/> />
) : field.type === 'month' ? ( ) : field.type === 'date' || field.type === 'datetime' || field.type === 'month' ? (
<FormDatePicker <FormDatePicker
aria-label={field.label} aria-label={field.label}
className="w-full" className="w-full"
mode="month" mode={field.type === 'month' ? 'month' : field.type === 'datetime' ? 'datetime' : 'date'}
type={field.type === 'datetime' ? 'datetime-local' : 'date'}
variant={filterFieldVariant} variant={filterFieldVariant}
value={filters[field.key] || ''} value={filters[field.key] || ''}
onChange={(event) => onFiltersChange?.(field.key, event.target.value)} onChange={(event) => onFiltersChange?.(field.key, event.target.value)}
/> />
) : field.type === 'reference' ? (
<ReferenceSearchSelect
fieldDef={field.fieldDef}
placeholder={field.placeholder || `筛选${field.label}`}
value={filters[field.key] || ''}
onChange={(value) => onFiltersChange?.(field.key, value)}
/>
) : field.type === 'user' ? (
<UserSearchSelect
ariaLabel={field.label}
multiple={Boolean(field.fieldDef?.multiple)}
placeholder={field.placeholder || `筛选${field.label}`}
value={filters[field.key] || ''}
onChange={(value) => onFiltersChange?.(field.key, value)}
/>
) : ( ) : (
<Input <Input
className="w-full" className="w-full"
type="text" type={field.type === 'number' ? 'number' : 'text'}
value={filters[field.key] || ''} value={filters[field.key] || ''}
onChange={(event) => onFiltersChange?.(field.key, event.target.value)} onChange={(event) => onFiltersChange?.(field.key, event.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
......
...@@ -5,18 +5,200 @@ import { ...@@ -5,18 +5,200 @@ import {
} from '@/services/DataService'; } from '@/services/DataService';
import { import {
buildViewColumns, buildViewColumns,
buildViewSearchColumns,
checkViewCondition, checkViewCondition,
formatFieldValue,
getFieldValue,
isViewAccessible, isViewAccessible,
normalizeText, normalizeText,
normalizeProjectYearDisplay, normalizeProjectYearDisplay,
recordMatchesRestrictions, recordMatchesRestrictions,
sortRecordsByView, sortRecordsByView,
toArray,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const QUERY_PAGE_SIZE = 1000; const QUERY_PAGE_SIZE = 1000;
const DEFAULT_RECORD_SORT = '-updated_at -created_at';
const RECORD_LEVEL_FIELDS = new Set([
'_id',
'name',
'title',
'status',
'subject',
'content',
'content_type',
'order',
'created_at',
'updated_at',
]);
function normalizeFilterField(field) {
const normalized = String(field || '').trim();
if (
!normalized ||
RECORD_LEVEL_FIELDS.has(normalized) ||
normalized.startsWith('metadata.')
) {
return normalized;
}
return `metadata.${normalized}`;
}
function escapeRegex(value) {
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function conditionToMongoClause(rule) {
const field = normalizeFilterField(rule?.field);
if (!field || rule?.value === undefined) {
return null;
}
const comparator = rule.comparator || '$eq';
const value = rule.value;
if (comparator === '$contains') {
const values = Array.isArray(value) ? value : [value];
const pattern = values.map((item) => escapeRegex(item)).filter(Boolean).join('|');
return pattern ? { [field]: { $regex: pattern, $options: 'i' } } : null;
}
if (['$in', '$nin'].includes(comparator)) {
return { [field]: { [comparator]: Array.isArray(value) ? value : [value] } };
}
if (['$ne', '$exists'].includes(comparator)) {
return { [field]: { [comparator]: value } };
}
return { [field]: value };
}
function getFieldDefinitionForPath(field, subject) {
const fieldName = String(field || '').replace(/^metadata\./, '');
if (fieldName === 'title') {
return { name: 'title', type: 'text' };
}
return (subject?.fields || []).find((item) => item.name === fieldName) || null;
}
function dateFilterClause(field, value) {
const dateText = String(value || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateText)) {
return null;
}
const start = new Date(`${dateText}T00:00:00.000Z`);
if (Number.isNaN(start.getTime())) {
return null;
}
const end = new Date(start);
end.setUTCDate(end.getUTCDate() + 1);
return {
[field]: {
$gte: start.toISOString(),
$lt: end.toISOString(),
},
};
}
function buildFieldFilterClause(field, value, fieldDef) {
if (value === undefined || value === null || value === '') {
return null;
}
const fieldType = String(fieldDef?.type || 'text').toLowerCase();
if (['date', 'datetime', 'date_time'].includes(fieldType)) {
return dateFilterClause(field, value);
}
if (fieldType === 'number') {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? { [field]: numberValue } : null;
}
if (fieldType === 'boolean') {
if (value === true || value === false) return { [field]: value };
if (value === 'true' || value === 'false') return { [field]: value === 'true' };
return null;
}
const values = (Array.isArray(value) ? value : [value])
.map((item) => typeof item === 'string' ? item.trim() : item)
.filter((item) => item !== '' && item !== null && item !== undefined);
if (values.length === 0) return null;
if (['reference', 'document', 'view', 'template', 'task', 'tag', 'group', 'category', 'user'].includes(fieldType)) {
return fieldDef?.multiple
? { [field]: { $in: values } }
: { [field]: values[0] };
}
if (fieldType === 'select') {
return fieldDef?.multiple
? { [field]: { $in: values } }
: { [field]: values[0] };
}
const pattern = values.map(escapeRegex).filter(Boolean).join('|');
return pattern ? { [field]: { $regex: pattern, $options: 'i' } } : null;
}
function viewSort(view) {
const sortColumns = toArray(view?.settings?.columns)
.filter((column) => column?.sort)
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
if (sortColumns.length === 0) {
return DEFAULT_RECORD_SORT;
}
return sortColumns
.map((column) => `${column.sort === 'desc' ? '-' : ''}${normalizeFilterField(column.field)}`)
.join(' ');
}
function buildRemoteFilter(view, filters = {}, baseFilter = {}, subject) {
const clauses = [];
if (baseFilter && Object.keys(baseFilter).length > 0) {
clauses.push(baseFilter);
}
toArray(view?.settings?.restrictions)
.filter((restriction) => restriction?.type === 'field')
.map((restriction) => conditionToMongoClause({
...restriction,
comparator: restriction.comparator || '$in',
}))
.filter(Boolean)
.forEach((clause) => clauses.push(clause));
const conditions = toArray(view?.settings?.filters?.conditions)
.map(conditionToMongoClause)
.filter(Boolean);
if (conditions.length > 0) {
const relationship = view?.settings?.filters?.relationship || 'all';
clauses.push(
relationship === 'any' || relationship === 'or'
? { $or: conditions }
: { $and: conditions },
);
}
Object.entries(filters || {})
.filter(([key]) => key.startsWith('field:'))
.map(([key, value]) => buildFieldFilterClause(
normalizeFilterField(key.slice('field:'.length)),
value,
getFieldDefinitionForPath(key.slice('field:'.length), subject),
))
.filter(Boolean)
.forEach((clause) => clauses.push(clause));
if (clauses.length === 0) {
return undefined;
}
if (clauses.length === 1) {
return clauses[0];
}
return { $and: clauses };
}
export function normalizeWorkbenchRecord(record) { export function normalizeWorkbenchRecord(record) {
return { return {
...@@ -57,18 +239,66 @@ export async function queryAllSubjectRecords(subjectName, query = {}) { ...@@ -57,18 +239,66 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
return records; return records;
} }
export async function loadSubjectWorkbenchResources(subjectName, query = {}) { export async function querySubjectWorkbenchRecords(
subjectName,
subject,
view,
filters = {},
query = {},
page = 0,
pageSize = 20,
) {
const response = await queryRecords({
...query,
subject: subjectName,
page,
page_size: pageSize,
mode: query.mode || 'full',
sort: query.sort || viewSort(view),
filter: buildRemoteFilter(view, filters, query.filter, subject),
});
const records = response?.records || [];
const totalCount = Number(
response?.total_count ??
response?.total_records ??
response?.total ??
response?.count ??
records.length,
);
return {
records,
totalCount: Number.isFinite(totalCount) ? totalCount : records.length,
};
}
export async function loadSubjectWorkbenchResources(
subjectName,
query = {},
{ viewName = '', filters = {}, page = 0, pageSize = 20 } = {},
) {
const [subject, views] = await Promise.all([ const [subject, views] = await Promise.all([
loadSubject(subjectName), loadSubject(subjectName),
getSubjectView(subjectName), getSubjectView(subjectName),
]); ]);
const accessibleGridViews = buildGridViews(views || []); const accessibleGridViews = buildGridViews(views || []);
const records = accessibleGridViews.length > 0 const activeView = accessibleGridViews.find((view) => view.name === viewName)
? await queryAllSubjectRecords(subjectName, query) || accessibleGridViews[0]
: []; || null;
const pageResult = activeView
? await querySubjectWorkbenchRecords(
subjectName,
subject,
activeView,
filters,
query,
page,
pageSize,
)
: { records: [], totalCount: 0 };
return { return {
records: records.map(normalizeWorkbenchRecord), records: pageResult.records.map(normalizeWorkbenchRecord),
totalCount: pageResult.totalCount,
subject, subject,
views: views || [], views: views || [],
}; };
...@@ -86,17 +316,12 @@ export function buildGridViews(views = []) { ...@@ -86,17 +316,12 @@ export function buildGridViews(views = []) {
return isViewAccessible(fallbackView) ? [fallbackView] : []; return isViewAccessible(fallbackView) ? [fallbackView] : [];
} }
export function buildConfiguredWorkbenchView(records, filters, view, subject) { export function buildConfiguredWorkbenchView(records, _filters, view, subject) {
if (!view) return { columns: [], filteredRecords: [] }; if (!view) return { columns: [], filteredRecords: [] };
const keyword = normalizeText(filters?.keyword).toLowerCase();
const status = normalizeText(filters?.status);
const year = normalizeText(filters?.year);
const columns = buildViewColumns(view, subject); const columns = buildViewColumns(view, subject);
const searchColumns = buildViewSearchColumns(view, subject);
const filteredRecords = records.filter((record) => { const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record; const rawRecord = record.raw || record;
const metadata = rawRecord?.metadata || {};
if (!recordMatchesRestrictions(rawRecord, view?.settings?.restrictions)) { if (!recordMatchesRestrictions(rawRecord, view?.settings?.restrictions)) {
return false; return false;
...@@ -114,24 +339,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) { ...@@ -114,24 +339,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return false; return false;
} }
if (status && normalizeText(metadata.project_status) !== status) {
return false;
}
if (year && normalizeText(metadata.project_year) !== year) {
return false;
}
if (!keyword) {
return true; return true;
}
const searchText = searchColumns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ')
.toLowerCase();
return searchText.includes(keyword);
}); });
return { return {
......
...@@ -27,8 +27,8 @@ ...@@ -27,8 +27,8 @@
## 2. 项目列表与详情 ## 2. 项目列表与详情
1. 页面通过 `loadSubject`、`getSubjectView` 和 `POST /record/query` 读取字段、视图和记录。 1. 页面通过 `loadSubject`、`getSubjectView` 和 `POST /record/query` 读取字段、视图和记录。
2. 表格列、视图限制、排序、选择字段选项和字段分组来自服务端配置;字段分组读取当前 Grid View 的 `settings.groups`,按配置展示分组表头并支持展开或收起。当前 Grid View 的 `settings.shortTitle === true` 时只隐藏通用 `title` 展示列;`false` 或未配置时正常展示。`title` 数据仍参与关键词搜索、排序、详情和保存,详情入口保留在项目名称和固定操作列。 2. 表格列、视图限制、排序、选择字段选项和字段分组来自服务端配置;字段分组读取当前 Grid View 的 `settings.groups`,按配置展示分组表头并支持展开或收起。当前 Grid View 的 `settings.shortTitle === true` 时只隐藏通用 `title` 展示列;`false` 或未配置时正常展示。`title` 数据参与排序、详情和保存,详情入口保留在项目名称和固定操作列。筛选清单根据当前 View 的字段配置和类型自动生成,文本类字段使用模糊匹配,选择/布尔字段使用枚举值,日期使用当天范围,数字使用精确值,引用/用户字段使用 ID;所有字段筛选与分页均通过 `POST /record/query` 远程执行,浏览器不再加载全部记录后本地过滤。
切换 Grid View 时立即切换当前视图、清空跨视图选择并回到第一页,同时重新读取 Subject、视图配置和 `/record/query` 数据;项目状态不再单独提供下拉筛选,按顶部 Grid View Tabs 切换;刷新期间不展示上一次请求的记录行,表格在当前可视区域中心显示加载状态,失败时保留错误反馈并清空不可确认的旧数据。 切换 Grid View 时立即切换当前视图、清空跨视图选择并回到第一页,同时重新读取 Subject、视图配置和 `/record/query` 数据;不再额外写死项目状态筛选,当前视图配置包含该字段时才自动生成对应的选择筛选,视图标签仍用于切换服务端配置的视图限制;刷新期间不展示上一次请求的记录行,表格在当前可视区域中心显示加载状态,失败时保留错误反馈并清空不可确认的旧数据。
项目列表表格使用限定高度的内部滚动区域,支持纵向和横向滚动;表头在表格滚动区域内吸顶,分页位于滚动区域外,避免页面因记录行过多持续向下延伸。 项目列表表格使用限定高度的内部滚动区域,支持纵向和横向滚动;表头在表格滚动区域内吸顶,分页位于滚动区域外,避免页面因记录行过多持续向下延伸。
View 中兼容历史字段 `metadata.project_finish_time` 时按数据定义显示为“项目完成时间”; View 中兼容历史字段 `metadata.project_finish_time` 时按数据定义显示为“项目完成时间”;
内部 `metadata.*` 路径不作为可见表头,字段取值路径和服务端配置保持不变。 内部 `metadata.*` 路径不作为可见表头,字段取值路径和服务端配置保持不变。
......
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