Commit 8789a8c6 authored by drigle's avatar drigle

feat: sync risk prevention workbench capabilities

parent 394be253
---
name: subject-schema-manager
description: Sync Stream Subject definitions into subjectsDefinition.md and apply explicitly confirmed Subject and field changes.
---
# Subject Schema Manager
The live Subject API is authoritative. Read `src/services/record-api.md` and `src/services/subjectsDefinition.md` before changing schema documentation or planning mutations.
## Sync
Discover root Subjects from source constants, recursively follow non-empty `field.settings.subject`, and rebuild only the managed object-definition block in `src/services/subjectsDefinition.md`. Sync never mutates remote objects. Use:
```bash
node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync --project "$PWD" --ticket-stdin
```
## Apply
For object or field creation, update, reorder, or deletion, first show an exact plan and obtain explicit confirmation. Keep the plan outside the repository, pass the ticket via stdin or `SUBJECT_TICKET`, and require `--confirmed`. Remote deletion additionally requires `--allow-remote-delete`. Never infer destructive operations from documentation cleanup.
Reference and user values are IDs, select values come from `settings.options`, nested Subjects are documented recursively, and dates use ISO 8601.
interface:
display_name: "Subject Schema Manager"
short_description: "Sync Subject docs and apply confirmed schema plans"
default_prompt: "Use $subject-schema-manager to sync current Subject definitions or apply a confirmed object/field configuration plan."
# Subject Mutation Plan
`apply` accepts `{ "version": 1, "operations": [] }`. Supported operations are `create_subject`, `update_subject`, `create_field`, `update_field`, `import_fields`, `reorder_fields`, `delete_subject`, and `delete_field`. Object and field aliases must contain only letters, digits, `_`, or `-`. Deletions require both `--confirmed` and `--allow-remote-delete`.
...@@ -107,6 +107,11 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守: ...@@ -107,6 +107,11 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守:
- 项目管理模块 - 请阅读 `/src/pages/project_manager_xy/project-management.business-flow.md` - 项目管理模块 - 请阅读 `/src/pages/project_manager_xy/project-management.business-flow.md`
- 廉洁风险防控模块 - 请阅读 `/src/pages/integrity_risk_prevention/integrity-risk-prevention.business-flow.md` - 廉洁风险防控模块 - 请阅读 `/src/pages/integrity_risk_prevention/integrity-risk-prevention.business-flow.md`
## Subject Schema Skill
- 项目内置 Skill 位于 `.agents/skills/subject-schema-manager/`,用于同步 `subjectsDefinition.md` 及执行已确认的 Subject 对象、字段和配置计划。
- 同步对象文档前使用 `node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync --project "$PWD" --ticket-stdin`;远程对象或字段变更必须先展示并确认计划,删除还需要 `--allow-remote-delete`。
## 获取当前登录用户信息 ## 获取当前登录用户信息
可使用 src/hooks/user.jsx 中的 hook useUserInfo 数据结构如下 { "username": "yang_admin", "password_changed": true, "token_expires_at": null, "\_id": "69b12a47e92b1de77c94a9f5", "display_name": "杨康", "type": "admin", "avatar": null, "profile": {}, "groups": [ { "\_id": "649e5a55ad1c1038911baabd", "name": "default", "display_name": "默认用户组", "users": [ { "_id": "649e5a55ad1c1038911baac0", "username": "system", "display_name": "系统" } ], "settings": {} } ], "applications": [ { "name": "fund_audit", "title": "数智报告平台", "groups": [ "default" ], "roles": [ "成员" ], "permissions": [] }, { "name": "aml_tables", "title": "反洗钱数据表", "groups": [ "default" ], "roles": [ "管理" ], "permissions": [] } ] } 可使用 src/hooks/user.jsx 中的 hook useUserInfo 数据结构如下 { "username": "yang_admin", "password_changed": true, "token_expires_at": null, "\_id": "69b12a47e92b1de77c94a9f5", "display_name": "杨康", "type": "admin", "avatar": null, "profile": {}, "groups": [ { "\_id": "649e5a55ad1c1038911baabd", "name": "default", "display_name": "默认用户组", "users": [ { "_id": "649e5a55ad1c1038911baac0", "username": "system", "display_name": "系统" } ], "settings": {} } ], "applications": [ { "name": "fund_audit", "title": "数智报告平台", "groups": [ "default" ], "roles": [ "成员" ], "permissions": [] }, { "name": "aml_tables", "title": "反洗钱数据表", "groups": [ "default" ], "roles": [ "管理" ], "permissions": [] } ] }
This diff is collapsed.
# Object Configuration Migration
对象、字段、视图和 ticket 换 token 的完整 HTTP API 参考见 [SUBJECT_API.md](./SUBJECT_API.md)。本文保留配置包迁移的详细说明。
The object configuration package moves metadata between Streams environments. It does not move records, database IDs, object members, tags, categories, connector settings, credentials, or existing external database tables.
## Workflow
1. Select one or more normal, embedded, or external objects in Admin > Object Management and choose `Export Configuration`.
2. Referenced objects are included automatically. The selected objects are listed in `selected_subjects`; dependency objects follow them in `subjects`.
3. In the target environment choose `Import Configuration` and select the JSON file.
4. Review the precheck. Only a package with no blocking issues can be applied.
5. The import uses `upsert`: matching objects and fields are updated, new ones are created, and target-only fields and views are preserved.
For an external object, create and enable a DM8 connector in the target environment before import. Its stable `connector.name` must match the alias exported in `external.connector`. A new external object provisions a new managed table using the `dm_<subject.name>` convention; import never adopts, binds to, or overwrites a pre-existing physical table.
## Stable identifiers
The following identifiers are portable business codes and must not be replaced with database IDs:
- object: `subject.name`
- field: `field.name`
- select option: `field.settings.options[].value`
- reference target: `field.settings.subject`
- external connector: `subject.external.connector` (`connector.name`)
For example:
```json
{
"kind": "streams.subject-configuration",
"version": 1,
"selected_subjects": ["orders"],
"include_dependencies": true,
"subjects": [
{
"name": "orders",
"title": "Orders",
"type": "normal",
"fields": [
{
"name": "status",
"label": "Status",
"type": "select",
"settings": {
"options": [
{"label": "Open", "value": "open"}
]
}
},
{
"name": "customer",
"label": "Customer",
"type": "reference",
"settings": {"subject": "customers"}
}
],
"views": []
},
{
"name": "customers",
"title": "Customers",
"type": "normal",
"fields": [],
"views": []
}
]
}
```
An external object only exports its connector alias:
```json
{
"name": "external_orders",
"title": "External Orders",
"type": "external",
"external": {
"connector": "business_dm"
},
"fields": [],
"views": []
}
```
The package never contains the connector host, account, password, settings, runtime state, physical table metadata, or records.
## API
- `POST /subject/configuration/export` with `{subjects: ["orders"], include_dependencies: true}`
- `POST /subject/configuration/import/preview` with `{mode: "upsert", package: {...}}`
- `POST /subject/configuration/import` with `{mode: "upsert", package: {...}}`
The import order is normal object shells, complete new external objects, object properties, normal fields, reference fields, and views. A new external object is created once with its complete field definition so the managed table is provisioned once. This also allows circular references between objects to be imported after referenced object names exist.
External-object precheck blocks import when:
- `external.connector` is missing or invalid;
- the same-alias connector does not exist, is inactive, or is not DM8-compatible;
- the target table for a new external object already exists;
- an existing external object's connector alias differs from the package;
- an existing external object is not in the `ready` state;
- an external field change is not supported by the managed-table schema.
## Deliberate limitations in V1
- Records are never included.
- Connector settings and credentials are never included; target connectors are resolved by alias.
- Existing physical tables are never adopted or overwritten by configuration import.
- Members, tags, and categories are reported as warnings and are not imported.
- Upsert never deletes fields or views that only exist in the target environment.
...@@ -39,9 +39,7 @@ export function DataTableEmptyState({ title = '暂无数据' }) { ...@@ -39,9 +39,7 @@ export function DataTableEmptyState({ title = '暂无数据' }) {
function DataTableLoadingState() { function DataTableLoadingState() {
return ( return (
<div className="flex min-h-48 items-center justify-center" aria-label="数据加载中"> <div className="min-h-48 w-full" aria-hidden="true" />
<Spinner size="sm" />
</div>
); );
} }
...@@ -227,7 +225,7 @@ export function DataTable({ ...@@ -227,7 +225,7 @@ export function DataTable({
data-state={rows.length > 0 ? 'populated' : isLoading ? 'loading' : 'empty'} data-state={rows.length > 0 ? 'populated' : isLoading ? 'loading' : 'empty'}
> >
<div ref={viewportRef} className="app-data-grid-viewport relative min-w-0 w-full"> <div ref={viewportRef} className="app-data-grid-viewport relative min-w-0 w-full">
{isLoading && rows.length > 0 ? ( {isLoading ? (
<div <div
className="absolute inset-0 z-10 grid place-items-center" className="absolute inset-0 z-10 grid place-items-center"
aria-label="数据加载中" aria-label="数据加载中"
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
uploadFile, uploadFile,
} from '@/services/DataService'; } from '@/services/DataService';
import { import {
buildViewColumns,
dateInputValue, dateInputValue,
emptyValueForField, emptyValueForField,
getFieldDisplayLabel, getFieldDisplayLabel,
...@@ -38,6 +39,24 @@ function isPrimaryField(fieldDef) { ...@@ -38,6 +39,24 @@ function isPrimaryField(fieldDef) {
return Boolean(fieldDef?.primary || fieldDef?.isPrimaryKey || fieldDef?.is_primary_key); return Boolean(fieldDef?.primary || fieldDef?.isPrimaryKey || fieldDef?.is_primary_key);
} }
function isConfiguredRequired(column) {
return Boolean(column?.required || column?.isRequired || column?.is_required ||
column?.settings?.required || column?.validation?.required);
}
function getDialogColumns(subject, view) {
const columns = buildViewColumns(view, subject, { includeHidden: true })
.filter((column) => column?.field && !column.hide)
.map((column) => column.field === 'title' ? {
...column,
fieldDef: {
...(column.fieldDef || {}), name: 'title', label: column.label || '记录标题',
type: 'text', required: isConfiguredRequired(column),
},
} : column);
return columns.length > 0 ? columns : buildViewColumns(view, subject).filter((column) => column?.field);
}
function isEmptyValue(value) { function isEmptyValue(value) {
return value === '' || value === null || value === undefined || ( return value === '' || value === null || value === undefined || (
Array.isArray(value) && value.length === 0 Array.isArray(value) && value.length === 0
...@@ -451,12 +470,17 @@ export default function DynamicRecordDialog({ ...@@ -451,12 +470,17 @@ export default function DynamicRecordDialog({
subject, subject,
subjectName, subjectName,
record, record,
view,
}) { }) {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [values, setValues] = useState({}); const [values, setValues] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const editing = Boolean(record); const editing = Boolean(record);
const dialogColumns = useMemo(() => getDialogColumns(subject, view), [subject, view]);
const titleColumn = dialogColumns.find((column) => column.field === 'title');
const dialogFields = dialogColumns.filter((column) => column.field !== 'title')
.map((column) => column.fieldDef).filter((field) => field?.name);
const initialValues = useMemo(() => { const initialValues = useMemo(() => {
return (subject?.fields || []).reduce((result, field) => { return (subject?.fields || []).reduce((result, field) => {
...@@ -480,7 +504,7 @@ export default function DynamicRecordDialog({ ...@@ -480,7 +504,7 @@ export default function DynamicRecordDialog({
try { try {
const prepared = await prepareValuesForSubmit(subject, values); const prepared = await prepareValuesForSubmit(subject, values);
const validationErrors = prepared.errors; const validationErrors = prepared.errors;
if (!title.trim()) { if (titleColumn?.fieldDef?.required && !title.trim()) {
validationErrors.unshift('记录标题为必填项'); validationErrors.unshift('记录标题为必填项');
} }
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
...@@ -488,16 +512,16 @@ export default function DynamicRecordDialog({ ...@@ -488,16 +512,16 @@ export default function DynamicRecordDialog({
return; return;
} }
if (editing) { if (editing) {
await updateRecord(record.name || record._id, { const payload = { metadata: prepared.metadata };
title: title.trim(), if (titleColumn) payload.title = title.trim();
metadata: prepared.metadata, await updateRecord(record.name || record._id, payload);
});
} else { } else {
await createRecord({ const payload = {
subject: subjectName, subject: subjectName,
title: title.trim(),
metadata: prepared.metadata, metadata: prepared.metadata,
}); };
if (titleColumn && title.trim()) payload.title = title.trim();
await createRecord(payload);
} }
await onSaved?.(); await onSaved?.();
onClose?.(); onClose?.();
...@@ -529,16 +553,16 @@ export default function DynamicRecordDialog({ ...@@ -529,16 +553,16 @@ export default function DynamicRecordDialog({
)} )}
> >
<div className="grid min-w-0 gap-x-6 gap-y-5 md:grid-cols-2"> <div className="grid min-w-0 gap-x-6 gap-y-5 md:grid-cols-2">
<TextField className="md:col-span-2" fullWidth isDisabled={saving} isRequired> {titleColumn ? <TextField className="md:col-span-2" fullWidth isDisabled={saving} isRequired={Boolean(titleColumn.fieldDef?.required)}>
<Label>记录标题</Label> <Label>{titleColumn.label || '记录标题'}</Label>
<Input <Input
placeholder="请输入标题" placeholder={`请输入${titleColumn.label || '记录标题'}`}
value={title} value={title}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
variant="secondary" variant="secondary"
/> />
</TextField> </TextField> : null}
{(subject?.fields || []).map((field) => { {dialogFields.map((field) => {
const isFullWidth = ['long_text', 'rich_text', 'object', 'form', 'subject', 'file'].includes( const isFullWidth = ['long_text', 'rich_text', 'object', 'form', 'subject', 'file'].includes(
String(field.type || '').toLowerCase(), String(field.type || '').toLowerCase(),
); );
......
...@@ -12,6 +12,8 @@ import { ...@@ -12,6 +12,8 @@ import {
import { import {
getFieldValue, getFieldValue,
getReferenceSubjectName, getReferenceSubjectName,
getSelectOptions,
isDateFieldType,
isReferenceFieldType, isReferenceFieldType,
normalizeRecordId, normalizeRecordId,
setFieldValue, setFieldValue,
...@@ -23,11 +25,32 @@ import DynamicRecordDialog from './DynamicRecordDialog'; ...@@ -23,11 +25,32 @@ import DynamicRecordDialog from './DynamicRecordDialog';
import { import {
buildConfiguredWorkbenchView, buildConfiguredWorkbenchView,
buildGridViews, buildGridViews,
buildViewCountMap,
loadSubjectWorkbenchResources, loadSubjectWorkbenchResources,
} from '@/services/SubjectWorkbenchService'; } from '@/services/SubjectWorkbenchService';
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 10;
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 type = null;
let options;
if (fieldType === 'select') { options = getSelectOptions(fieldDef); type = options.length ? 'select' : 'text'; }
else if (fieldType === 'boolean') { type = 'select'; options = BOOLEAN_FILTER_OPTIONS; }
else if (isDateFieldType(fieldType)) type = 'date';
else if (fieldType === 'number') type = 'number';
else if (fieldType === 'user') type = 'user';
else if (isReferenceFieldType(fieldType)) type = getReferenceSubjectName(fieldDef) ? 'reference' : 'text';
else if (['text', 'long_text', 'rich_text'].includes(fieldType)) type = 'text';
if (!type) return fields;
fields.push({ key: `field:${field}`, field, fieldDef, label: column.label || field, type, options, placeholder: `筛选${column.label || field}` });
return fields;
}, []);
}
function collectExpandedValueIds(rows, columns) { function collectExpandedValueIds(rows, columns) {
const userIds = new Set(); const userIds = new Set();
...@@ -144,7 +167,8 @@ export default function SubjectRecordWorkbench({ ...@@ -144,7 +167,8 @@ 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: '' }); const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({});
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = DEFAULT_PAGE_SIZE; const pageSize = DEFAULT_PAGE_SIZE;
...@@ -161,7 +185,7 @@ export default function SubjectRecordWorkbench({ ...@@ -161,7 +185,7 @@ export default function SubjectRecordWorkbench({
} }
try { try {
const resources = await loadSubjectWorkbenchResources(subjectName, query); const resources = await loadSubjectWorkbenchResources(subjectName, query, { viewName: activeViewName, filters });
setRecords(resources.records || []); setRecords(resources.records || []);
setSubject(resources.subject || null); setSubject(resources.subject || null);
setViews(resources.views || []); setViews(resources.views || []);
...@@ -173,7 +197,7 @@ export default function SubjectRecordWorkbench({ ...@@ -173,7 +197,7 @@ export default function SubjectRecordWorkbench({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [subjectName, query]); }, [activeViewName, filters, subjectName, query]);
useEffect(() => { useEffect(() => {
reload(); reload();
...@@ -287,21 +311,7 @@ export default function SubjectRecordWorkbench({ ...@@ -287,21 +311,7 @@ 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( const filterSchema = useMemo(() => buildFilterSchema(visibleColumns), [visibleColumns]);
() => buildViewCountMap(records, filters, gridViews, subject),
[records, filters, gridViews, subject],
);
const filterSchema = useMemo(() => {
return [
{
key: 'keyword',
label: '关键词搜索',
type: 'text',
icon: 'search',
placeholder: '请输入关键词',
},
];
}, []);
const feedbackHelpers = useMemo(() => ({ const feedbackHelpers = useMemo(() => ({
reload, reload,
...@@ -463,22 +473,24 @@ export default function SubjectRecordWorkbench({ ...@@ -463,22 +473,24 @@ 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={(viewName) => { onViewChange={(viewName) => {
setActiveViewName(viewName); setActiveViewName(viewName);
setFilters({});
setPendingFilters({});
setSelectedIds([]); setSelectedIds([]);
setPage(0); setPage(0);
}} }}
filters={filters} filters={pendingFilters}
filterSchema={filterSchema} filterSchema={filterSchema}
onFiltersChange={(key, value) => { onFiltersChange={(key, value) => {
setFilters((current) => ({ ...current, [key]: value })); setPendingFilters((current) => ({ ...current, [key]: value }));
setPage(0);
setSelectedIds([]);
}} }}
onQuery={() => { setFilters(pendingFilters); setPage(0); setSelectedIds([]); }}
onClearFilters={() => { onClearFilters={() => {
setFilters({ keyword: '' }); setFilters({});
setPendingFilters({});
setPage(0); setPage(0);
setSelectedIds([]); setSelectedIds([]);
}} }}
...@@ -549,6 +561,7 @@ export default function SubjectRecordWorkbench({ ...@@ -549,6 +561,7 @@ export default function SubjectRecordWorkbench({
subject={subject} subject={subject}
subjectName={subjectName} subjectName={subjectName}
record={editingRecord} record={editingRecord}
view={activeView}
/> />
<XinyuanModal <XinyuanModal
......
...@@ -14,7 +14,6 @@ import { ...@@ -14,7 +14,6 @@ import {
Tag, Tag,
TagGroup, TagGroup,
} from '@heroui/react'; } from '@heroui/react';
import { useState } from 'react';
function defaultFilter(textValue, inputValue) { function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase(); const query = String(inputValue || '').trim().toLocaleLowerCase();
...@@ -23,12 +22,12 @@ function defaultFilter(textValue, inputValue) { ...@@ -23,12 +22,12 @@ function defaultFilter(textValue, inputValue) {
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) { function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') { if (selectionMode === 'multiple') {
const keys = selectedKeys === 'all' ? ['all'] : selectedKeys || []; if (selectedKeys === 'all') return new Set();
return new Set([...keys].map((key) => String(key))); return new Set(Array.from(selectedKeys || []).map(toSelectionKey).filter(Boolean));
} }
return new Set( return new Set(
selectedKey === null || selectedKey === undefined ? [] : [String(selectedKey)], selectedKey === null || selectedKey === undefined ? [] : [toSelectionKey(selectedKey)].filter(Boolean),
); );
} }
...@@ -38,6 +37,7 @@ function toSelectionKey(value) { ...@@ -38,6 +37,7 @@ function toSelectionKey(value) {
export default function XinyuanAutocomplete({ export default function XinyuanAutocomplete({
'aria-label': ariaLabel, 'aria-label': ariaLabel,
allowsEmptyCollection = false,
className, className,
emptyText = '暂无可选项', emptyText = '暂无可选项',
errorText = '', errorText = '',
...@@ -66,12 +66,7 @@ export default function XinyuanAutocomplete({ ...@@ -66,12 +66,7 @@ export default function XinyuanAutocomplete({
valueText, valueText,
variant, variant,
}) { }) {
const [internalInputValue, setInternalInputValue] = useState(''); const resolvedInputValue = inputValue ?? '';
const resolvedInputValue = inputValue ?? internalInputValue;
const handleInputChange = (nextValue) => {
setInternalInputValue(nextValue);
onInputChange?.(nextValue);
};
const selectedKeySet = getSelectedKeySet( const selectedKeySet = getSelectedKeySet(
selectionMode, selectionMode,
selectedKey, selectedKey,
...@@ -80,34 +75,12 @@ export default function XinyuanAutocomplete({ ...@@ -80,34 +75,12 @@ export default function XinyuanAutocomplete({
const selectedKeyStringSet = new Set( const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey), Array.from(selectedKeySet).map(toSelectionKey),
); );
const optionMap = new Map( const selectedOptions = options.filter((option) => selectedKeyStringSet.has(toSelectionKey(option.id)));
options.map((option) => [toSelectionKey(option.id), option]),
);
const selectedOptions = selectionMode === 'multiple' && selectedKeys === 'all'
? options
: [...selectedKeyStringSet].map((key) =>
optionMap.get(key) || {
id: key,
label: key,
},
);
const resolvedValueText = const resolvedValueText =
valueText ?? valueText ??
(selectedOptions.length > 0 (selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、') ? selectedOptions.map((option) => option.label).join('、')
: placeholder); : placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys: selectedKeyStringSet }
: { selectedKey };
const listBoxSelectionProps =
selectionMode === 'multiple'
? {
selectionMode,
selectedKeys: selectedKeyStringSet,
}
: {};
const rootOpenProps = typeof isOpen === 'boolean' ? { isOpen } : {};
const activeFilter = shouldFilter ? filter || defaultFilter : null; const activeFilter = shouldFilter ? filter || defaultFilter : null;
const visibleOptions = isLoading || errorText const visibleOptions = isLoading || errorText
? [] ? []
...@@ -152,18 +125,32 @@ export default function XinyuanAutocomplete({ ...@@ -152,18 +125,32 @@ export default function XinyuanAutocomplete({
); );
onSelectionChange?.(nextKeys); onSelectionChange?.(nextKeys);
}; };
const handleMultipleOptionAction = (optionKey) => {
const nextKeys = new Set(selectedKeyStringSet);
const key = toSelectionKey(optionKey);
if (!key) return;
if (nextKeys.has(key)) nextKeys.delete(key); else nextKeys.add(key);
onSelectionChange?.(nextKeys);
};
const normalizedSelectionProps = selectionMode === 'multiple'
? { selectedKey: null }
: { selectedKey: selectedKeySet.values().next().value || null };
const normalizedListProps = selectionMode === 'multiple'
? { selectionMode: 'single', onAction: handleMultipleOptionAction }
: { selectionMode: 'single', selectedKeys: selectedKeySet, onSelectionChange };
return ( return (
<Autocomplete <Autocomplete
{...rootOpenProps} {...normalizedSelectionProps}
{...rootSelectionProps}
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
allowsEmptyCollection={allowsEmptyCollection}
className={className} className={className}
fullWidth={fullWidth} fullWidth={fullWidth}
isDisabled={isDisabled} isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired} isRequired={isRequired}
name={name} name={name}
selectionMode={selectionMode} selectionMode={selectionMode === 'multiple' ? 'single' : selectionMode}
variant={variant} variant={variant}
onClear={handleClear} onClear={handleClear}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
...@@ -219,7 +206,7 @@ export default function XinyuanAutocomplete({ ...@@ -219,7 +206,7 @@ export default function XinyuanAutocomplete({
<Autocomplete.Filter <Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true} filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={resolvedInputValue} inputValue={resolvedInputValue}
onInputChange={handleInputChange} onInputChange={onInputChange}
> >
{popoverHeader} {popoverHeader}
<SearchField <SearchField
...@@ -245,7 +232,7 @@ export default function XinyuanAutocomplete({ ...@@ -245,7 +232,7 @@ export default function XinyuanAutocomplete({
]} ]}
items={visibleOptions} items={visibleOptions}
slot={null} slot={null}
{...listBoxSelectionProps} {...normalizedListProps}
renderEmptyState={() => ( renderEmptyState={() => (
<div <div
className="grid min-h-20 place-items-center px-3 py-4" className="grid min-h-20 place-items-center px-3 py-4"
......
...@@ -21,6 +21,8 @@ import { ...@@ -21,6 +21,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,
...@@ -909,19 +911,35 @@ export default function RecordWorkbench({ ...@@ -909,19 +911,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}
......
...@@ -68,6 +68,13 @@ ...@@ -68,6 +68,13 @@
### 2.1 Subject View 授权 ### 2.1 Subject View 授权
#### 2.1.1 通用列表与新增记录交互
- 列表字段、字段标题和筛选项均从当前 Grid View 配置生成;切换视图会清空当前筛选和勾选状态。
- 筛选控件按视图字段类型生成,引用和用户字段通过远程搜索选择,查询提交后按字段值向 `/record/query` 发送远程 `filter`;列表不再提供关键字搜索。
- 新建记录弹窗只渲染当前视图配置的字段,包含视图显式配置的 `title` 字段;标题是否显示及是否必填遵循视图配置,业务字段仍按 Subject 定义写入 `metadata`。
- 列表加载期间 loading 指示器在表格区域居中显示;查询失败保留错误状态,不提交成功反馈。
- Grid View 的 `settings.viewAuth` 只控制视图可见性。普通用户只读取 - Grid View 的 `settings.viewAuth` 只控制视图可见性。普通用户只读取
`user.applications[]` 中 `name = "integrity_risk_prevention_new"` 的 groups、roles 和 `user.applications[]` 中 `name = "integrity_risk_prevention_new"` 的 groups、roles 和
permissions;缺少配置默认拒绝。系统管理员、系统账号和该应用自身“管理”角色可旁路。 permissions;缺少配置默认拒绝。系统管理员、系统账号和该应用自身“管理”角色可旁路。
......
...@@ -6,17 +6,80 @@ import { ...@@ -6,17 +6,80 @@ import {
} from '@/services/DataService'; } from '@/services/DataService';
import { import {
buildViewColumns, buildViewColumns,
buildViewSearchColumns,
checkViewCondition, checkViewCondition,
formatFieldValue,
getFieldValue,
isViewAccessible, isViewAccessible,
normalizeText, normalizeText,
recordMatchesRestrictions, recordMatchesRestrictions,
sortRecordsByView, sortRecordsByView,
toArray,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const QUERY_PAGE_SIZE = 1000; const QUERY_PAGE_SIZE = 1000;
const RECORD_LEVEL_FIELDS = new Set(['_id', 'name', 'title', 'status', 'subject', 'content', 'content_type', 'order', 'created_at', 'updated_at']);
function normalizeFilterField(field) {
const value = String(field || '').trim();
return !value || RECORD_LEVEL_FIELDS.has(value) || value.startsWith('metadata.') ? value : `metadata.${value}`;
}
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 pattern = toArray(value).map(escapeRegex).filter(Boolean).join('|');
return pattern ? { [field]: { $regex: pattern, $options: 'i' } } : null;
}
if (['$in', '$nin'].includes(comparator)) return { [field]: { [comparator]: toArray(value) } };
if (['$ne', '$exists'].includes(comparator)) return { [field]: { [comparator]: value } };
return { [field]: value };
}
function getFieldDefinition(field, subject) {
const name = String(field || '').replace(/^metadata\./, '');
return name === 'title' ? { name: 'title', type: 'text' } : (subject?.fields || []).find((item) => item.name === name);
}
function buildFieldFilterClause(field, value, fieldDef) {
if (value === undefined || value === null || value === '') return null;
const type = String(fieldDef?.type || 'text').toLowerCase();
if (['date', 'datetime', 'date_time'].includes(type)) {
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`);
const end = new Date(start); end.setUTCDate(end.getUTCDate() + 1);
return { [field]: { $gte: start.toISOString(), $lt: end.toISOString() } };
}
if (type === 'number') return Number.isFinite(Number(value)) ? { [field]: Number(value) } : null;
if (type === 'boolean') return value === true || value === false ? { [field]: value } : { [field]: value === 'true' };
const values = toArray(value).map((item) => typeof item === 'string' ? item.trim() : item).filter((item) => item !== '');
if (values.length === 0) return null;
if (['reference', 'document', 'view', 'template', 'task', 'tag', 'group', 'category', 'user', 'select'].includes(type)) {
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 buildRemoteFilter(view, filters, baseFilter, subject) {
const clauses = [];
if (baseFilter && Object.keys(baseFilter).length > 0) clauses.push(baseFilter);
toArray(view?.settings?.restrictions).filter((item) => item?.type === 'field')
.map((item) => conditionToMongoClause({ ...item, comparator: item.comparator || '$in' })).filter(Boolean).forEach((item) => clauses.push(item));
const conditions = toArray(view?.settings?.filters?.conditions).map(conditionToMongoClause).filter(Boolean);
if (conditions.length > 0) clauses.push(['any', 'or'].includes(view?.settings?.filters?.relationship) ? { $or: conditions } : { $and: conditions });
Object.entries(filters || {}).filter(([key]) => key.startsWith('field:')).map(([key, value]) => {
const field = normalizeFilterField(key.slice(6));
return buildFieldFilterClause(field, value, getFieldDefinition(field, subject));
}).filter(Boolean).forEach((item) => clauses.push(item));
if (clauses.length === 0) return undefined;
return clauses.length === 1 ? clauses[0] : { $and: clauses };
}
export function normalizeWorkbenchRecord(record) { export function normalizeWorkbenchRecord(record) {
return { return {
...@@ -57,7 +120,7 @@ export async function queryAllSubjectRecords(subjectName, query = {}) { ...@@ -57,7 +120,7 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
return records; return records;
} }
export async function loadSubjectWorkbenchResources(subjectName, query = {}) { export async function loadSubjectWorkbenchResources(subjectName, query = {}, options = {}) {
const [subject, viewSummaries] = await Promise.all([ const [subject, viewSummaries] = await Promise.all([
loadSubject(subjectName), loadSubject(subjectName),
getSubjectView(subjectName), getSubjectView(subjectName),
...@@ -79,8 +142,12 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) { ...@@ -79,8 +142,12 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
}), }),
); );
const accessibleGridViews = buildGridViews(views); const accessibleGridViews = buildGridViews(views);
const records = accessibleGridViews.length > 0 const activeView = options.view || accessibleGridViews.find((view) => view.name === options.viewName) || accessibleGridViews[0];
? await queryAllSubjectRecords(subjectName, query) const records = activeView
? await queryAllSubjectRecords(subjectName, {
...query,
filter: buildRemoteFilter(activeView, options.filters, query?.filter, subject),
})
: []; : [];
return { return {
...@@ -97,9 +164,7 @@ export function buildGridViews(views = []) { ...@@ -97,9 +164,7 @@ export function buildGridViews(views = []) {
} }
export function buildConfiguredWorkbenchView(records, filters, view, subject) { export function buildConfiguredWorkbenchView(records, filters, view, subject) {
const keyword = normalizeText(filters?.keyword).toLowerCase();
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;
...@@ -119,16 +184,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) { ...@@ -119,16 +184,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return false; 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 {
......
This diff is collapsed.
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