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`.
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const START_MARKER = '<!-- SUBJECT_SCHEMA_SYNC:START -->';
const END_MARKER = '<!-- SUBJECT_SCHEMA_SYNC:END -->';
const ALIAS_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const VOLATILE_KEYS = new Set([
'_id', 'created_at', 'updated_at', 'deleted_at', '__v', 'created_by',
'table', 'state', 'schema_version',
]);
const SUBJECT_PATCH_KEYS = new Set([
'title', 'description', 'order', 'tags', 'categories', 'templates', 'settings',
'access', 'members', 'search', 'history',
]);
const FIELD_PATCH_KEYS = new Set([
'type', 'label', 'description', 'default', 'primary', 'required', 'multiple',
'search', 'aggregation', 'settings',
]);
function usage() {
console.error(`Usage:
subject_schema_manager.mjs sync --project <path> [--ticket-stdin] [--check] [--roots a,b]
subject_schema_manager.mjs apply --project <path> --plan-file <path> --confirmed [--ticket-stdin]
Options:
--base-url <url> API base; otherwise STREAMS_API_URL or .umirc.* proxy target
--doc <path> subjectsDefinition.md path relative to project or absolute path
--ticket-stdin read the ticket from stdin (never prints it)
--allow-remote-delete required for explicit delete_subject/delete_field operations
`);
}
function parseArgs(argv) {
const [command, ...rest] = argv;
if (!command || !['sync', 'apply'].includes(command)) {
usage();
throw new Error('command must be sync or apply');
}
const options = { command, roots: [] };
for (let index = 0; index < rest.length; index += 1) {
const value = rest[index];
if (value === '--check' || value === '--confirmed' || value === '--ticket-stdin' || value === '--allow-remote-delete') {
options[value.slice(2).replaceAll('-', '_')] = true;
continue;
}
if (value.startsWith('--')) {
const key = value.slice(2).replaceAll('-', '_');
const next = rest[index + 1];
if (!next || next.startsWith('--')) throw new Error(`${value} requires a value`);
index += 1;
if (key === 'roots') options.roots = next.split(',').map((item) => item.trim()).filter(Boolean);
else options[key] = next;
continue;
}
throw new Error(`unknown argument: ${value}`);
}
return options;
}
function findProjectDoc(project, explicitDoc) {
if (explicitDoc) return path.resolve(project, explicitDoc);
const candidates = [
path.join(project, 'src/services/subjectsDefinition.md'),
path.join(project, 'subjectsDefinition.md'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}
function readText(file) {
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
}
function walkSourceFiles(directory, result = []) {
if (!fs.existsSync(directory)) return result;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (entry.name.startsWith('.') || ['node_modules', 'dist', 'build', '.next'].includes(entry.name)) continue;
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) walkSourceFiles(fullPath, result);
else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) result.push(fullPath);
}
return result;
}
function discoverRoots(project, explicitRoots) {
if (explicitRoots?.length) return [...new Set(explicitRoots)];
const sourceRoot = path.join(project, 'src');
const roots = new Set();
const constantPattern = /(?:^|\n|\r)\s*(?:export\s+)?const\s+[A-Z][A-Z0-9_]*SUBJECT[A-Z0-9_]*\s*=\s*['"]([A-Za-z][A-Za-z0-9_-]*)['"]/g;
const literalSubjectPattern = /\bsubject\s*:\s*['"]([A-Za-z][A-Za-z0-9_-]*)['"]/g;
const loadSubjectPattern = /\bloadSubject\(\s*['"]([A-Za-z][A-Za-z0-9_-]*)['"]\s*\)/g;
for (const file of walkSourceFiles(sourceRoot)) {
const source = readText(file);
for (const pattern of [constantPattern, literalSubjectPattern, loadSubjectPattern]) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(source))) roots.add(match[1]);
}
}
if (!roots.size) throw new Error('No static Subject roots found; pass --roots alias1,alias2');
return [...roots].sort();
}
function resolveBaseUrl(project, explicitBase) {
const candidate = explicitBase || process.env.STREAMS_API_URL;
if (candidate && /^https?:\/\//i.test(candidate)) return candidate.replace(/\/$/, '');
const configFiles = ['.umirc.ts', '.umirc.js', '.umirc.tsx', 'config/config.ts', 'config/config.js'];
for (const file of configFiles) {
const source = readText(path.join(project, file));
const matches = [...source.matchAll(/target\s*:\s*['"](https?:\/\/[^'"]+)['"]/g)];
const apiTarget = matches.find((match) => /\/api(?:\/|$)/.test(match[1])) || matches[0];
if (apiTarget) return apiTarget[1].replace(/\/$/, '');
}
if (candidate && candidate.startsWith('/')) {
throw new Error(`API base ${candidate} is relative; pass --base-url with an absolute URL for the Node helper`);
}
throw new Error('Cannot resolve API base URL; pass --base-url or set STREAMS_API_URL');
}
function joinUrl(baseUrl, requestPath) {
return new URL(requestPath.replace(/^\//, ''), `${baseUrl.replace(/\/$/, '')}/`).toString();
}
function safeErrorMessage(status, payload) {
const message = payload && typeof payload === 'object'
? payload.message || payload.error || payload.code
: '';
return `${status}${message ? `: ${String(message).slice(0, 300)}` : ''}`;
}
async function request(baseUrl, token, requestPath, { method = 'GET', body } = {}) {
const response = await fetch(joinUrl(baseUrl, requestPath), {
method,
headers: {
Accept: 'application/json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
let payload = null;
if (text) {
try { payload = JSON.parse(text); } catch { payload = { message: text.slice(0, 300) }; }
}
if (!response.ok) throw new Error(`API ${method} ${requestPath} failed (${safeErrorMessage(response.status, payload)})`);
return payload;
}
function readTicket(options) {
const ticket = process.env.SUBJECT_TICKET?.trim()
|| (options.ticket_stdin ? fs.readFileSync(0, 'utf8').trim() : '');
if (!ticket) throw new Error('Provide the ticket via SUBJECT_TICKET or --ticket-stdin');
return ticket;
}
async function exchangeTicket(baseUrl, ticket) {
const payload = await request(baseUrl, '', '/auth/login', {
method: 'POST',
body: { type: 'ticket', ticket },
});
const token = payload?.token || payload?.data?.token || payload?.result?.token;
if (!token) throw new Error('Ticket exchange succeeded but no API token was returned');
return token;
}
function findNested(value, predicate, depth = 0) {
if (depth > 5 || value === null || typeof value !== 'object') return null;
if (predicate(value)) return value;
for (const child of Object.values(value)) {
const found = findNested(child, predicate, depth + 1);
if (found) return found;
}
return null;
}
function normalizeSubject(payload, expectedName) {
const subject = findNested(payload, (value) => (
value && typeof value === 'object' && value.name === expectedName
&& (Array.isArray(value.fields) || typeof value.type === 'string' || typeof value.title === 'string')
));
if (!subject) throw new Error(`API response did not contain Subject ${expectedName}`);
return { ...subject, fields: Array.isArray(subject.fields) ? subject.fields : undefined };
}
function normalizeFields(payload) {
const fields = findNested(payload, (value) => Array.isArray(value)
&& value.every((item) => item && typeof item === 'object' && typeof item.name === 'string'));
return fields || [];
}
async function fetchSubject(baseUrl, token, name) {
let subject;
try {
subject = normalizeSubject(await request(baseUrl, token, `/subject/${encodeURIComponent(name)}`), name);
} catch (error) {
if (/\b404\b|NOT_FOUND|not found/i.test(error.message)) throw new Error(`Subject ${name} could not be resolved (${error.message})`);
throw error;
}
if (!Array.isArray(subject.fields)) {
subject.fields = normalizeFields(await request(baseUrl, token, `/subject/${encodeURIComponent(name)}/field`));
}
return subject;
}
function dependencyNames(subject) {
const names = [];
for (const field of subject.fields || []) {
if (field?.type === 'form') continue;
const target = field?.settings?.subject;
if (typeof target === 'string' && target.trim()) names.push(target.trim());
}
return [...new Set(names)];
}
async function fetchClosure(baseUrl, token, roots) {
const subjects = new Map();
const unresolved = [];
const queue = [...roots];
while (queue.length) {
const name = queue.shift();
if (subjects.has(name)) continue;
try {
const subject = await fetchSubject(baseUrl, token, name);
subjects.set(name, subject);
for (const target of dependencyNames(subject)) {
if (!subjects.has(target)) queue.push(target);
}
} catch (error) {
unresolved.push({ name, reason: error.message });
}
}
return { subjects, unresolved };
}
function stableClone(value) {
if (Array.isArray(value)) return value.map(stableClone);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().filter((key) => !VOLATILE_KEYS.has(key)).map((key) => [key, stableClone(value[key])]));
}
function json(value) {
return JSON.stringify(stableClone(value), null, 2);
}
function fieldSettingsSummary(field) {
const settings = field?.settings || {};
const target = settings.subject ? `Subject: ${settings.subject}` : '';
const options = Array.isArray(settings.options) ? `options: ${settings.options.map((item) => item?.value ?? item?.label ?? item).join(', ')}` : '';
const extras = Object.entries(settings)
.filter(([key]) => !['subject', 'options'].includes(key))
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`);
return [target, options, ...extras].filter(Boolean).join('; ');
}
function renderSubject(subject) {
const fields = subject.fields || [];
const lines = [
`### \`${subject.name}\`:${subject.title || subject.name}`,
'',
`- 类型:\`${subject.type || 'unknown'}\``,
`- 字段数量:${fields.length}`,
subject.description ? `- 说明:${subject.description}` : '',
'',
'| 字段标签 | 字段别名 | 字段类型 | 多值 | 必填 | 主键 | 关联/配置 |',
'| --- | --- | --- | --- | --- | --- | --- |',
...fields.map((field) => [
field.label || field.name,
`\`${field.name}\``,
field.type || '',
field.multiple ? '是' : '否',
field.required ? '是' : '否',
field.primary ? '是' : '否',
fieldSettingsSummary(field),
].map((cell) => String(cell).replaceAll('|', '\\|')).join(' | ').replace(/^/, '| ').concat(' |')),
'',
'<details>',
'<summary>完整对象与字段配置</summary>',
'',
'```json',
json(subject),
'```',
'',
'</details>',
'',
];
return lines.filter((line, index) => !(line === '' && lines[index - 1] === '')) .join('\n');
}
function renderManagedBlock(subjects, roots, unresolved) {
const ordered = [...subjects.values()].sort((a, b) => {
const ai = roots.indexOf(a.name); const bi = roots.indexOf(b.name);
if (ai >= 0 || bi >= 0) return (ai < 0 ? 1 : bi < 0 ? -1 : ai - bi);
return a.name.localeCompare(b.name);
});
const lines = [START_MARKER, '', `> 同步根对象:${roots.map((name) => `\`${name}\``).join('、')}`, `> 当前依赖闭包:${ordered.length} 个 Subject。`, ''];
for (const subject of ordered) lines.push(renderSubject(subject));
lines.push('### 关联对象与子对象定义', '', '依赖字段通过 `settings.subject` 指向的对象会递归纳入本节;`form` 资源不属于 Subject 依赖。', '');
for (const subject of ordered) {
for (const field of subject.fields || []) {
if (field?.type === 'form') continue;
const target = field?.settings?.subject;
if (typeof target === 'string' && target.trim()) lines.push(`- \`${subject.name}.${field.name}\` -> \`${target.trim()}\``);
}
}
if (unresolved.length) {
lines.push('', '### 未解析的关联对象', '', ...unresolved.map((item) => `- \`${item.name}\`:${item.reason}`));
}
lines.push('', END_MARKER);
return lines.join('\n');
}
function replaceManagedBlock(document, block) {
const start = document.indexOf(START_MARKER);
const end = document.indexOf(END_MARKER);
if (start >= 0 && end >= 0 && end > start) {
const replaced = `${document.slice(0, start)}${block}${document.slice(end + END_MARKER.length)}`;
return removeLegacyDependencySection(replaced);
}
const heading = /^## 2\.\s*Subject Definitions[^\n]*\n?/m.exec(document);
if (heading) {
const contentStart = heading.index + heading[0].length;
const nextHeading = /^## (?!#)[^\n]*\n?/gm;
nextHeading.lastIndex = contentStart;
const next = nextHeading.exec(document);
const sectionEnd = next ? next.index : document.length;
const section = document.slice(contentStart, sectionEnd);
const objectHeadings = [...section.matchAll(/^### [^\n]*`([A-Za-z][A-Za-z0-9_-]*)`[^\n]*\n?/gm)];
let migratedSection = section;
if (objectHeadings.length) {
for (let index = objectHeadings.length - 1; index >= 0; index -= 1) {
const current = objectHeadings[index];
const startIndex = current.index;
const endIndex = objectHeadings[index + 1]?.index ?? section.length;
migratedSection = `${migratedSection.slice(0, startIndex)}${migratedSection.slice(endIndex)}`;
}
const firstIndex = objectHeadings[0].index;
migratedSection = `${migratedSection.slice(0, firstIndex)}\n${block}\n\n${migratedSection.slice(firstIndex)}`;
} else {
migratedSection = `\n${block}\n\n${section}`;
}
let migrated = `${document.slice(0, contentStart)}${migratedSection}${document.slice(sectionEnd)}`;
return removeLegacyDependencySection(migrated);
}
return `${document.trimEnd()}\n\n## 2. Subject Definitions\n\n${block}\n`;
}
function removeLegacyDependencySection(document) {
const legacy = /^## 3\.\s*关联对象与子对象定义[^\n]*\n?/m.exec(document);
if (!legacy) return document;
const next = /^## 4\.\s*类型写入协议[^\n]*\n?/m.exec(document.slice(legacy.index + legacy[0].length));
const nextIndex = next ? legacy.index + legacy[0].length + next.index : document.length;
return `${document.slice(0, legacy.index)}${document.slice(nextIndex)}`;
}
async function synchronize({ project, doc, baseUrl, token, roots, check = false }) {
const closure = await fetchClosure(baseUrl, token, roots);
const current = readText(doc);
const next = replaceManagedBlock(current, renderManagedBlock(closure.subjects, roots, closure.unresolved));
if (check) {
if (current !== next) {
console.error(`Documentation is out of date: ${path.relative(project, doc)}`);
process.exitCode = 2;
} else {
console.log(`Documentation is synchronized: ${closure.subjects.size} Subject(s)`);
}
} else {
fs.mkdirSync(path.dirname(doc), { recursive: true });
fs.writeFileSync(doc, next, 'utf8');
console.log(`Synchronized ${closure.subjects.size} Subject(s) in ${path.relative(project, doc)}`);
}
if (closure.unresolved.length) {
console.error(`Unresolved dependency count: ${closure.unresolved.length}`);
}
return closure;
}
function assertAlias(value, label) {
if (typeof value !== 'string' || !ALIAS_RE.test(value)) throw new Error(`${label} must match ${ALIAS_RE}`);
}
function validateField(field, label) {
if (!field || typeof field !== 'object') throw new Error(`${label} must be an object`);
assertAlias(field.name, `${label}.name`);
if (typeof field.label !== 'string' || !field.label.trim()) throw new Error(`${label}.label is required`);
if (typeof field.type !== 'string' || !field.type.trim()) throw new Error(`${label}.type is required`);
if (field.type === 'select') {
if (!Array.isArray(field.settings?.options)) throw new Error(`${label}.settings.options is required for select fields`);
const values = field.settings.options.map((option) => option?.value);
if (values.some((value) => value === undefined || value === null || value === '') || new Set(values).size !== values.length) {
throw new Error(`${label}.settings.options must have unique non-empty values`);
}
}
if (['reference', 'subject'].includes(field.type) || field.settings?.subject) {
if (typeof field.settings?.subject !== 'string' || !field.settings.subject.trim()) throw new Error(`${label}.settings.subject is required for a Subject relation`);
}
}
function validatePlan(plan, options) {
if (!plan || plan.version !== 1 || !Array.isArray(plan.operations)) throw new Error('Plan must contain version: 1 and an operations array');
const destructive = new Set(['delete_subject', 'delete_field']);
for (const [index, operation] of plan.operations.entries()) {
const label = `operations[${index}]`;
if (!operation || typeof operation.op !== 'string') throw new Error(`${label}.op is required`);
if (destructive.has(operation.op) && !options.allow_remote_delete) throw new Error(`${label} is destructive; pass --allow-remote-delete explicitly`);
if (operation.op === 'create_subject') {
if (!operation.subject || typeof operation.subject !== 'object') throw new Error(`${label}.subject is required`);
assertAlias(operation.subject.name, `${label}.subject.name`);
if (typeof operation.subject.title !== 'string' || !operation.subject.title.trim()) throw new Error(`${label}.subject.title is required`);
if (typeof operation.subject.type !== 'string' || !operation.subject.type.trim()) throw new Error(`${label}.subject.type is required`);
for (const [fieldIndex, field] of (operation.subject.fields || []).entries()) validateField(field, `${label}.subject.fields[${fieldIndex}]`);
} else if (operation.op === 'update_subject') {
assertAlias(operation.subject, `${label}.subject`);
validatePatch(operation.patch, SUBJECT_PATCH_KEYS, label);
} else if (operation.op === 'create_field') {
assertAlias(operation.subject, `${label}.subject`); validateField(operation.field, `${label}.field`);
} else if (operation.op === 'update_field') {
assertAlias(operation.subject, `${label}.subject`); assertAlias(operation.field, `${label}.field`); validatePatch(operation.patch, FIELD_PATCH_KEYS, label);
} else if (operation.op === 'import_fields') {
assertAlias(operation.subject, `${label}.subject`);
if (!Array.isArray(operation.fields)) throw new Error(`${label}.fields must be an array`);
for (const [fieldIndex, field] of operation.fields.entries()) validateField(field, `${label}.fields[${fieldIndex}]`);
} else if (operation.op === 'reorder_fields') {
assertAlias(operation.subject, `${label}.subject`);
if (!Array.isArray(operation.fields) || operation.fields.some((field) => !ALIAS_RE.test(field))) throw new Error(`${label}.fields must be an alias array`);
} else if (operation.op === 'delete_subject') {
assertAlias(operation.subject, `${label}.subject`);
if (operation.force !== undefined && typeof operation.force !== 'boolean') throw new Error(`${label}.force must be boolean`);
if (operation.confirm !== undefined && operation.confirm !== operation.subject) throw new Error(`${label}.confirm must equal the subject alias`);
} else if (operation.op === 'delete_field') {
assertAlias(operation.subject, `${label}.subject`); assertAlias(operation.field, `${label}.field`);
if (operation.confirm !== undefined && operation.confirm !== operation.field) throw new Error(`${label}.confirm must equal the field alias`);
} else {
throw new Error(`${label}.op ${operation.op} is not supported`);
}
}
}
function validatePatch(patch, allowed, label) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error(`${label}.patch must be an object`);
const invalid = Object.keys(patch).filter((key) => !allowed.has(key));
if (invalid.length) throw new Error(`${label}.patch contains unsupported properties: ${invalid.join(', ')}`);
}
async function applyPlan({ baseUrl, token, plan, options }) {
validatePlan(plan, options);
const creations = plan.operations.filter((operation) => operation.op === 'create_subject');
for (const operation of creations) {
await request(baseUrl, token, '/subject?preview=true', { method: 'POST', body: operation.subject });
}
const results = [];
for (const operation of plan.operations) {
let response;
switch (operation.op) {
case 'create_subject': response = await request(baseUrl, token, '/subject', { method: 'POST', body: operation.subject }); break;
case 'update_subject': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}`, { method: 'PUT', body: operation.patch }); break;
case 'create_field': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field`, { method: 'POST', body: operation.field }); break;
case 'update_field': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/${encodeURIComponent(operation.field)}`, { method: 'PUT', body: operation.patch }); break;
case 'import_fields': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/import`, { method: 'PUT', body: { fields: operation.fields } }); break;
case 'reorder_fields': response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/reorder`, { method: 'PUT', body: { fields: operation.fields } }); break;
case 'delete_subject': {
const query = new URLSearchParams();
if (operation.force) query.set('force', 'true');
if (operation.confirm) query.set('confirm', operation.confirm);
const suffix = query.toString() ? `?${query.toString()}` : '';
response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}${suffix}`, { method: 'DELETE' });
break;
}
case 'delete_field': {
const suffix = operation.confirm ? `?confirm=${encodeURIComponent(operation.confirm)}` : '';
response = await request(baseUrl, token, `/subject/${encodeURIComponent(operation.subject)}/field/${encodeURIComponent(operation.field)}${suffix}`, { method: 'DELETE' });
break;
}
default: throw new Error(`Unsupported operation ${operation.op}`);
}
results.push({ operation: operation.op, subject: operation.subject, field: operation.field, response });
console.log(`Applied ${operation.op} ${operation.subject}${operation.field ? `.${operation.field}` : ''}`);
}
return results;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const project = path.resolve(options.project || process.cwd());
const baseUrl = resolveBaseUrl(project, options.base_url);
const doc = findProjectDoc(project, options.doc);
if (options.command === 'sync') {
const roots = discoverRoots(project, options.roots);
roots.forEach((root) => assertAlias(root, 'Subject root'));
const ticket = readTicket(options);
const token = await exchangeTicket(baseUrl, ticket);
await synchronize({ project, doc, baseUrl, token, roots, check: options.check });
return;
}
if (!options.confirmed) throw new Error('apply requires --confirmed after an explicit human-readable plan confirmation');
if (!options.plan_file) throw new Error('apply requires --plan-file');
const plan = JSON.parse(fs.readFileSync(path.resolve(options.plan_file), 'utf8'));
validatePlan(plan, options);
const roots = discoverRoots(project, options.roots);
roots.forEach((root) => assertAlias(root, 'Subject root'));
const ticket = readTicket(options);
const token = await exchangeTicket(baseUrl, ticket);
await applyPlan({ baseUrl, token, plan, options });
await synchronize({ project, doc, baseUrl, token, roots, check: false });
}
main().catch((error) => {
console.error(`subject-schema-manager: ${error.message}`);
process.exitCode = 1;
});
......@@ -107,6 +107,11 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守:
- 项目管理模块 - 请阅读 `/src/pages/project_manager_xy/project-management.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": [] } ] }
# Streams 对象、字段与配置 API
本文档描述 Streams 中对象(Subject)、字段(Field)、视图(View)和对象配置包的 HTTP API,并补充通过登录票据换取 API Token 的调用方式。
## 1. 基本约定
- Base URL 由部署环境决定,本文档中的路径均不包含域名。
- 请求和响应默认使用 `application/json`。服务端 JSON 请求体上限为 100 MB。
- 本文档涉及的对象、字段、视图和配置迁移接口,除 `GET /subject/field/type` 外都经过认证中间件,需要有效登录。
- Token 可放在请求头 `Authorization: Bearer <token>` 中,也可以使用服务端写入的 `streams_auth_token` 签名 Cookie。
- 对象和字段的稳定标识使用别名(`name`),不是 MongoDB 的 `_id`。
- 成功响应通常直接返回对象、字段数组或操作结果;错误响应统一包含 `code`、`status`、`message`,部分错误还包含 `details`。
对象详情、对象更新和配置导入还会执行对象级访问检查;外部对象的字段结构变更会额外检查 `write` 权限和托管表所有权。字段/视图接口的路由要求登录,调用方仍应在网关或应用权限层限制可写对象范围。
示例请求头:
```http
Authorization: Bearer eyJ...
Content-Type: application/json
```
## 2. 认证:通过 ticket 换 token
### 2.1 为用户生成登录票据
```http
PUT /user/{username}/ticket
Authorization: Bearer <管理员Token>
```
请求体为空。该接口位于用户管理的管理员权限范围内,调用方需要登录并具备管理员权限。
成功响应:
```json
{
"ticket": "3c1c8a..."
}
```
每次调用都会覆盖该用户之前的 ticket。ticket 是登录凭证,应通过 HTTPS 传输并避免写入日志。
### 2.2 使用 ticket 换取 API token
```http
POST /auth/login
Content-Type: application/json
```
请求体:
```json
{
"type": "ticket",
"ticket": "3c1c8a..."
}
```
成功响应:
```json
{
"username": "alice",
"password_changed": true,
"token": "a7d2...",
"token_expires_at": null
}
```
ticket 登录不会设置 `streams_auth_token` Cookie,客户端应保存响应中的 `token`,并在后续请求中发送 `Authorization: Bearer <token>`。ticket 登录得到的 token 当前不会设置过期时间(响应中的 `token_expires_at` 为 `null`);实际部署仍应按安全策略定期重新生成 ticket。
常见错误:
| HTTP 状态 | `message` | 说明 |
| --- | --- | --- |
| 400/500 | `ticket is required` | 未提供 `ticket` |
| 400/500 | `没有找到用户` | ticket 无效或用户不存在 |
| 400/500 | `用户已被禁用` | 用户状态不是 `active` |
错误状态码取决于 Koa 错误是否带有显式状态;生产环境不应依赖 `message` 以外的内部堆栈信息。
## 3. 字段类型
### 3.1 查询支持的字段类型
```http
GET /subject/field/type
```
成功响应是字符串数组:
```json
[
"text", "long_text", "boolean", "number", "select", "date",
"rich_text", "formula", "object", "file", "action", "application",
"subject", "reference", "rule", "view", "template", "task", "tag",
"category", "document", "user", "group", "form"
]
```
类型按用途可以分为:
| 类型 | 用途和说明 |
| --- | --- |
| `text` | 单行文本 |
| `long_text` | 长文本 |
| `rich_text` | 富文本 |
| `boolean` | 布尔值 |
| `number` | 数字 |
| `date` | 日期或时间 |
| `select` | 单选或多选;选项放在 `settings.options` |
| `formula` | 公式计算字段;公式放在 `settings.formula`,不保存为外部表列 |
| `object` | JSON 对象 |
| `file` | 文件值或文件列表 |
| `form` | 表单关联值 |
| `subject` | 嵌入对象/对象值 |
| `reference` | 引用另一个对象;目标对象放在 `settings.subject` |
| `application`、`rule`、`view`、`template`、`task` | 对应平台对象关联 |
| `tag`、`category` | 标签或分类关联 |
| `document` | 文档关联 |
| `user`、`group` | 用户或用户组关联 |
| `action` | 动作字段;不保存为外部表列 |
服务端对 `settings` 不做统一的键级校验,具体设置由字段类型和前端约定决定。`select`、`reference`/`subject` 是配置迁移预检时会额外校验的类型。
### 3.2 字段对象结构
字段可用于创建、更新和对象配置包:
| 属性 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `name` | string | 创建时是 | 自动生成 | 2~128 个字符;普通对象允许 `A-Z a-z 0-9 _ - .`,外部对象字段名还必须符合 SQL 标识符规则 |
| `label` | string | 是 | 无 | 展示名称,最长 128 个字符 |
| `description` | string | 否 | `""` | 最长 512 个字符 |
| `default` | any | 否 | `null` | 新记录或结构变更回填时使用的默认值 |
| `multiple` | boolean | 否 | `false` | 是否允许数组值 |
| `required` | boolean | 否 | `false` | 是否要求有值 |
| `primary` | boolean | 否 | `false` | 是否作为主字段;外部对象主字段必须是必填、单值且可索引类型 |
| `type` | string | 是 | `text` | 必须是 `/subject/field/type` 返回的类型 |
| `search` | object | 否 | `{ "enabled": false }` | 当前支持 `enabled` |
| `aggregation` | object | 否 | `{ "enabled": false }` | 支持 `enabled` 和父级字段名 `parent` |
| `settings` | object | 否 | `{}` | 类型专用配置,见下表 |
常用 `settings`:
```json
{
"options": [
{"label": "启用", "value": "enabled"},
{"label": "停用", "value": "disabled"}
]
}
```
| 字段类型 | 必要或常用设置 |
| --- | --- |
| `select` | `options` 必须是数组;每个选项必须有非空且唯一的 `value`,可选 `label` |
| `reference`、`subject` | `subject` 为目标对象别名,例如 `{ "subject": "customers" }` |
| `formula` | `formula` 为公式表达式 |
| 其他类型 | 使用 `settings` 扩展类型特定配置;未识别键会原样保存 |
## 4. 对象 API
### 4.1 对象结构
```json
{
"name": "orders",
"title": "订单",
"description": "订单主数据",
"type": "normal",
"order": 0,
"access": "public",
"history": true,
"templates": {
"title": "{{name}}",
"name": "order",
"content": ""
},
"settings": {},
"search": {
"enabled": true,
"title": {"enabled": true, "vector": false},
"name": {"enabled": true, "vector": false},
"content": {"enabled": true, "vector": false},
"vector": {"enabled": false, "embedding_model": ""}
},
"fields": [],
"views": []
}
```
对象属性:
| 属性 | 可选值/限制 | 说明 |
| --- | --- | --- |
| `name` | 2~128 个字符,`^[\w-]*$` | 全局唯一别名;创建后不支持通过更新接口改名 |
| `title` | 最长 128 个字符 | 展示名称 |
| `description` | 最长 512 个字符 | 描述 |
| `type` | `normal`、`embedded`、`external` | 对象类型;已存在对象不能切换类型 |
| `order` | number | 列表排序值 |
| `access` | `public`、`protected`、`private` | 访问级别;`protected` 结合 `members` 权限 |
| `history` | boolean | 是否记录历史;外部对象始终不启用 |
| `templates` | object | 标题、名称和内容模板 |
| `settings` | object | 对象级扩展配置 |
| `search` | object | 搜索和向量索引配置;更新时按对象合并 |
| `fields` | Field[] | 字段列表,字段名必须唯一 |
| `views` | View[] | 视图列表,视图名必须唯一 |
### 4.2 查询对象
查询全部可见对象:
```http
GET /subject
```
分页、筛选和排序查询:
```http
POST /subject/query
Content-Type: application/json
```
请求体示例:
```json
{
"page": 0,
"page_size": 20,
"status": "active",
"type": ["normal", "external"],
"keyword": "订单",
"tags": "tag_name",
"sort": "-order -created_at _id",
"filter": {}
}
```
响应:
```json
{
"current_page": 0,
"page_size": 20,
"total_pages": 1,
"total_subjects": 1,
"subjects": []
}
```
`page` 从 0 开始,`page_size` 默认 20,最大 1000。未指定 `type` 时默认查询 `normal` 和 `external`,不包含 `embedded`。
获取单个对象:
```http
GET /subject/{subject}
```
`{subject}` 可以是对象别名或 MongoDB ObjectId。对象不存在时返回 404;无访问权限时返回 403。
### 4.3 创建对象
```http
POST /subject
POST /subject?preview=true
Content-Type: application/json
```
普通对象示例:
```json
{
"name": "orders",
"title": "订单",
"type": "normal",
"access": "public",
"history": true,
"fields": [
{"name": "order_no", "label": "订单号", "type": "text", "primary": true, "required": true},
{"name": "status", "label": "状态", "type": "select", "settings": {"options": [
{"label": "待支付", "value": "pending"},
{"label": "已完成", "value": "completed"}
]}}
],
"views": []
}
```
`preview=true` 只执行模型构造和校验,不写入数据库,也不创建外部表。
外部对象示例:
```json
{
"name": "external_orders",
"title": "外部订单",
"type": "external",
"external": {"connector": "business_dm"},
"fields": [
{"name": "order_no", "label": "订单号", "type": "text", "primary": true, "required": true}
]
}
```
外部对象创建要求目标 connector 存在且是受支持的 SQL 数据源;服务端按 `dm_<name>` 生成表名并一次性建表。配置包导入还会额外要求 connector 处于启用状态。请求中的外部 `table`、`state`、`schema_version` 等运行时字段不会由客户端决定。
### 4.4 更新对象配置
```http
PUT /subject/{subject}
Content-Type: application/json
```
请求体只需要包含要变更的属性:
```json
{
"title": "销售订单",
"description": "更新后的描述",
"order": 10,
"access": "protected",
"templates": {"title": "{{order_no}}"},
"settings": {"layout": "wide"},
"search": {
"enabled": true,
"content": {"enabled": true, "vector": true}
},
"history": false,
"tags": [],
"categories": [],
"members": []
}
```
可更新属性为 `title`、`description`、`order`、`tags`、`categories`、`templates`、`settings`、`access`、`members`、`search` 和(非外部对象的)`history`。`name`、`type`、`status`、`external` 不能通过此接口修改。`search` 是深度合并,`settings`、`templates`、`members` 等对象属性按请求值替换。
### 4.5 删除、恢复对象
软删除(进入回收站):
```http
DELETE /subject/{subject}
```
恢复:
```http
PUT /subject/{subject}/restore
```
永久删除已在回收站中的对象:
```http
DELETE /subject/{subject}?force=true
```
外部对象永久删除还必须提供确认参数,值为删除前对象别名的前缀:
```http
DELETE /subject/external_orders?force=true&confirm=external_orders
```
外部对象的软删除、恢复和永久删除会同步检查托管表所有权;永久删除会删除由 Streams 托管的物理表。
## 5. 字段 API
以下 `{subject}` 是对象别名或 ObjectId,`{field}` 是字段别名。
### 5.1 查询字段
```http
GET /subject/{subject}/field
GET /subject/{subject}/field/{field}
```
第一个接口返回字段数组,第二个接口返回单个字段。字段不存在时,单字段接口返回空结果或 404,客户端应同时检查响应状态和内容。
### 5.2 创建字段
```http
POST /subject/{subject}/field
Content-Type: application/json
```
请求体示例:
```json
{
"name": "customer",
"label": "客户",
"description": "下单客户",
"type": "reference",
"required": false,
"multiple": false,
"search": {"enabled": true},
"settings": {"subject": "customers"}
}
```
普通对象会将字段追加到 `fields` 末尾并保存整个对象。字段 `name` 必须唯一。外部对象会创建一条外部 schema change,并同步执行 SQL 列变更;外部对象必须处于 `external.state=ready`,且当前用户拥有该托管表。
成功响应为更新后的完整对象(包含 `fields` 和 `views`),不是单独的字段值。
### 5.3 更新字段
```http
PUT /subject/{subject}/field/{field}
Content-Type: application/json
```
请求体示例:
```json
{
"label": "客户名称",
"description": "更新后的描述",
"required": true,
"search": {"enabled": true},
"aggregation": {"enabled": true, "parent": "customer_group"},
"settings": {"subject": "customers"}
}
```
普通对象支持更新 `type`、`label`、`description`、`default`、`primary`、`required`、`multiple`、`search`、`aggregation` 和 `settings`;字段别名由路径参数确定,不支持改名。未提供的属性保持原值。
成功响应为更新后的完整对象。
当前实现仅在 `default != null` 时写入 `default`,因此不能用 `{"default": null}` 清除已有默认值;需要清除时应通过完整字段导入或直接调整数据模型后再调用接口。
外部对象的更新会经过 schema change 流程。字段类型变更只有在底层存储兼容时才允许;将已有字段改为 `required` 时,如果已有记录为空,必须同时提供可用于回填的 `default`。
### 5.4 批量导入或更新字段
```http
PUT /subject/{subject}/field/import
Content-Type: application/json
```
请求体:
```json
{
"fields": [
{"name": "order_no", "label": "订单号", "type": "text", "required": true},
{"name": "amount", "label": "金额", "type": "number", "settings": {"unit": "CNY"}}
]
}
```
普通对象中,同名字段会按请求字段覆盖属性,新字段只有在包含 `label` 时才会追加;目标环境中只存在的字段会保留。外部对象同样走单条 schema change,成功后响应为对象字段数组。
### 5.5 调整字段顺序
```http
PUT /subject/{subject}/field/reorder
Content-Type: application/json
```
请求体必须提供字段别名数组:
```json
{
"fields": ["order_no", "customer", "status", "amount"]
}
```
数组应包含对象当前的全部字段,并按期望顺序排列。目标环境中未出现在数组里的字段会被排到最前面,因此不要提交不完整列表。外部对象在有未完成 schema change 时会拒绝排序。
成功响应为排序后的字段数组。
### 5.6 删除字段
普通对象:
```http
DELETE /subject/{subject}/field/{field}
```
外部对象删除已持久化字段会删除外部数据库列及其中数据,必须在查询参数中原样确认字段名:
```http
DELETE /subject/external_orders/field/amount?confirm=amount
```
不提供正确确认值时返回 `EXTERNAL_FIELD_DELETE_CONFIRMATION_REQUIRED`,不会执行删除。非持久化字段(`formula`、`action`)不需要删除外部列确认。
成功响应为更新后的完整对象。
删除影响预览:
```http
GET /subject/{subject}/field/{field}/removal-preview
```
示例响应:
```json
{
"field": "amount",
"persisted": true,
"total_records": 1200,
"affected_records": 860
}
```
该预览接口只对外部对象开放,`affected_records` 表示字段非空的记录数。
## 6. 视图配置 API
视图结构:
```json
{
"name": "recent",
"title": "最近订单",
"description": "按创建时间倒序",
"type": "table",
"settings": {
"filter": {"status": "pending"},
"sort": ["-created_at"]
}
}
```
`name` 需为 2~128 个字符且在对象内唯一,`title` 最长 128 个字符,`description` 最长 512 个字符。`settings` 的具体键由视图类型解释。
接口:
| 方法 | 路径 | 请求体 | 说明 |
| --- | --- | --- | --- |
| GET | `/subject/{subject}/view` | 无 | 返回视图数组 |
| GET | `/subject/{subject}/view/{view}` | 无 | 返回单个视图 |
| POST | `/subject/{subject}/view` | 完整或部分 View,创建时需 `name`、`title` | 创建视图并追加到末尾 |
| PUT | `/subject/{subject}/view/{view}` | `title`、`description`、`type`、`settings` | 更新指定视图;`name` 不可改 |
| PUT | `/subject/{subject}/view/reorder` | `{ "views": ["recent", "all"] }` | 调整顺序 |
| DELETE | `/subject/{subject}/view/{view}` | 无 | 删除视图 |
视图创建、更新和删除成功时返回更新后的完整对象;排序接口返回排序后的视图数组。
示例:
```http
POST /subject/orders/view
Content-Type: application/json
{"name":"pending","title":"待支付","type":"table","settings":{"filter":{"status":"pending"}}}
```
## 7. 外部对象专用 API
外部对象使用 connector 连接 SQL 数据源,并由 Streams 托管物理表。配置接口只接受稳定的 connector 别名,不接受主机、账号、密码等凭据。
### 7.1 重试建表
```http
POST /subject/{subject}/external/retry
```
用于外部对象初次建表失败后的重试。成功后 `external.state` 变为 `ready`;失败时变为 `error` 并保留错误信息。
### 7.2 查询和重试字段结构变更
```http
GET /subject/{subject}/external/schema-change
POST /subject/{subject}/external/schema-change/retry
```
查询响应:
```json
{
"current": null,
"changes": []
}
```
字段新增、更新、批量导入和删除都会产生 schema change 记录。失败且仍处于可重试状态时调用 retry;存在未完成变更时,不能并发提交另一条字段结构变更。
### 7.3 创建外部副本和恢复内部对象
从普通对象创建外部副本:
```http
POST /subject/{subject}/external/convert
Content-Type: application/json
{"connector":"business_dm","table":"dm_orders"}
```
也可以省略 `table`,服务端使用默认表名。只有 `normal` 对象支持转换;目标表必须是可验证的 Streams 托管表,已有任意物理表不会被自动接管或覆盖。
恢复为普通对象:
```http
POST /subject/{subject}/external/restore
```
该操作只解除对象与外部表的绑定,不自动删除外部表;服务端会检查表所有权和可恢复状态。
## 8. 对象配置包迁移 API
完整的配置包格式、字段校验、依赖处理、外部 connector 前置条件和限制见 [SUBJECT_CONFIGURATION.md](./SUBJECT_CONFIGURATION.md)。下面列出调用入口。
### 8.1 导出
```http
POST /subject/configuration/export
Content-Type: application/json
```
请求体:
```json
{
"subjects": ["orders"],
"include_dependencies": true
}
```
`include_dependencies` 默认为 `true`。字段类型为 `reference` 或 `subject` 且设置了 `settings.subject` 时,被引用的对象会自动加入配置包。调用方必须拥有选中对象的读取权限。
### 8.2 预检
```http
POST /subject/configuration/import/preview
Content-Type: application/json
```
请求体可以直接传配置包,也可以包在 `package` 属性中:
```json
{
"mode": "upsert",
"package": {
"kind": "streams.subject-configuration",
"version": 1,
"subjects": []
}
}
```
预检响应包含 `can_import`、`errors`、`warnings`、`summary` 和逐对象的创建/更新差异:
```json
{
"kind": "streams.subject-configuration",
"version": 1,
"mode": "upsert",
"can_import": true,
"errors": [],
"warnings": [],
"summary": {
"subjects_total": 1,
"subjects_create": 1,
"subjects_update": 0,
"subjects_unchanged": 0,
"subjects_error": 0,
"fields_create": 2,
"fields_update": 0,
"fields_reorder": 0,
"views_create": 0,
"views_update": 0,
"views_reorder": 0
},
"subjects": []
}
```
`mode` 当前只支持 `upsert`。预检会检查对象和字段别名、字段类型、重复选择项、引用目标、字段类型冲突、权限以及外部 connector/物理表可用性。
### 8.3 导入
```http
POST /subject/configuration/import
Content-Type: application/json
```
请求体与预检相同。服务端会再次执行预检,只有 `can_import=true` 才会写入;预检失败返回 `SUBJECT_CONFIGURATION_PRECHECK_FAILED`,错误详情中包含完整预检报告。
导入是 upsert:
1. 创建普通对象空壳,使引用对象名先存在。
2. 创建新的外部对象,并使用完整字段定义只建表一次。
3. 更新对象属性。
4. 导入普通字段,再导入引用字段。
5. 按配置包顺序重排字段。
6. 创建或更新视图并重排视图。
配置包中没有的目标字段和视图会保留,不执行删除。记录、成员、标签、分类、connector 配置和凭据都不包含在配置包中。
## 9. 常见错误码
| 错误码 | 适用场景 |
| --- | --- |
| `SUBJECT_NOT_FOUND` / `Subject not found` | 对象不存在 |
| `Field not found` / `EXTERNAL_FIELD_NOT_FOUND` | 字段不存在 |
| `view not found` | 视图不存在 |
| `Forbidden` | 未通过对象或管理员权限检查 |
| `Unauthorized` | 未提供有效认证信息 |
| `INVALID_FIELDS` | 批量字段请求缺少数组 `fields` |
| `INVALID_FIELD_TYPE` | 字段类型不在支持列表 |
| `DUPLICATE_FIELD` / `EXTERNAL_FIELD_DUPLICATE` | 字段别名重复 |
| `INVALID_SELECT_OPTIONS` / `DUPLICATE_SELECT_OPTION` | 选择字段选项缺失或值重复 |
| `MISSING_REFERENCE_TARGET` | 引用字段缺少 `settings.subject` |
| `EXTERNAL_CONNECTOR_NOT_FOUND` | 外部 connector 不存在 |
| `EXTERNAL_CONNECTOR_INACTIVE` | 外部 connector 未启用 |
| `EXTERNAL_CONNECTOR_UNSUPPORTED` | connector 不是受支持的 SQL 数据源 |
| `EXTERNAL_TABLE_EXISTS` | 目标物理表已存在,创建不会覆盖或接管 |
| `EXTERNAL_SUBJECT_NOT_READY` | 外部对象仍在建表或处于错误状态 |
| `EXTERNAL_SCHEMA_CHANGE_IN_PROGRESS` | 已有字段结构变更未完成 |
| `EXTERNAL_SCHEMA_CHANGE_UNSUPPORTED` | 底层 SQL 列类型不兼容 |
| `EXTERNAL_REQUIRED_FIELD_DEFAULT_REQUIRED` | 给已有记录增加必填字段时缺少回填默认值 |
| `EXTERNAL_FIELD_DELETE_CONFIRMATION_REQUIRED` | 删除外部持久化字段未确认字段名 |
| `SUBJECT_CONFIGURATION_PRECHECK_FAILED` | 配置包预检未通过 |
收到外部 schema change 错误时,先调用 `GET /subject/{subject}/external/schema-change` 查看 `current` 和 `changes`,确认没有并发变更后再重试或修正字段定义。
# 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 = '暂无数据' }) {
function DataTableLoadingState() {
return (
<div className="flex min-h-48 items-center justify-center" aria-label="数据加载中">
<Spinner size="sm" />
</div>
<div className="min-h-48 w-full" aria-hidden="true" />
);
}
......@@ -227,7 +225,7 @@ export function DataTable({
data-state={rows.length > 0 ? 'populated' : isLoading ? 'loading' : 'empty'}
>
<div ref={viewportRef} className="app-data-grid-viewport relative min-w-0 w-full">
{isLoading && rows.length > 0 ? (
{isLoading ? (
<div
className="absolute inset-0 z-10 grid place-items-center"
aria-label="数据加载中"
......
......@@ -11,6 +11,7 @@ import {
uploadFile,
} from '@/services/DataService';
import {
buildViewColumns,
dateInputValue,
emptyValueForField,
getFieldDisplayLabel,
......@@ -38,6 +39,24 @@ function isPrimaryField(fieldDef) {
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) {
return value === '' || value === null || value === undefined || (
Array.isArray(value) && value.length === 0
......@@ -451,12 +470,17 @@ export default function DynamicRecordDialog({
subject,
subjectName,
record,
view,
}) {
const [title, setTitle] = useState('');
const [values, setValues] = useState({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
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(() => {
return (subject?.fields || []).reduce((result, field) => {
......@@ -480,7 +504,7 @@ export default function DynamicRecordDialog({
try {
const prepared = await prepareValuesForSubmit(subject, values);
const validationErrors = prepared.errors;
if (!title.trim()) {
if (titleColumn?.fieldDef?.required && !title.trim()) {
validationErrors.unshift('记录标题为必填项');
}
if (validationErrors.length > 0) {
......@@ -488,16 +512,16 @@ export default function DynamicRecordDialog({
return;
}
if (editing) {
await updateRecord(record.name || record._id, {
title: title.trim(),
metadata: prepared.metadata,
});
const payload = { metadata: prepared.metadata };
if (titleColumn) payload.title = title.trim();
await updateRecord(record.name || record._id, payload);
} else {
await createRecord({
const payload = {
subject: subjectName,
title: title.trim(),
metadata: prepared.metadata,
});
};
if (titleColumn && title.trim()) payload.title = title.trim();
await createRecord(payload);
}
await onSaved?.();
onClose?.();
......@@ -529,16 +553,16 @@ export default function DynamicRecordDialog({
)}
>
<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>
<Label>记录标题</Label>
{titleColumn ? <TextField className="md:col-span-2" fullWidth isDisabled={saving} isRequired={Boolean(titleColumn.fieldDef?.required)}>
<Label>{titleColumn.label || '记录标题'}</Label>
<Input
placeholder="请输入标题"
placeholder={`请输入${titleColumn.label || '记录标题'}`}
value={title}
onChange={(event) => setTitle(event.target.value)}
variant="secondary"
/>
</TextField>
{(subject?.fields || []).map((field) => {
</TextField> : null}
{dialogFields.map((field) => {
const isFullWidth = ['long_text', 'rich_text', 'object', 'form', 'subject', 'file'].includes(
String(field.type || '').toLowerCase(),
);
......
......@@ -12,6 +12,8 @@ import {
import {
getFieldValue,
getReferenceSubjectName,
getSelectOptions,
isDateFieldType,
isReferenceFieldType,
normalizeRecordId,
setFieldValue,
......@@ -23,11 +25,32 @@ import DynamicRecordDialog from './DynamicRecordDialog';
import {
buildConfiguredWorkbenchView,
buildGridViews,
buildViewCountMap,
loadSubjectWorkbenchResources,
} from '@/services/SubjectWorkbenchService';
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) {
const userIds = new Set();
......@@ -144,7 +167,8 @@ export default function SubjectRecordWorkbench({
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [activeViewName, setActiveViewName] = useState('');
const [filters, setFilters] = useState({ keyword: '' });
const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({});
const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0);
const pageSize = DEFAULT_PAGE_SIZE;
......@@ -161,7 +185,7 @@ export default function SubjectRecordWorkbench({
}
try {
const resources = await loadSubjectWorkbenchResources(subjectName, query);
const resources = await loadSubjectWorkbenchResources(subjectName, query, { viewName: activeViewName, filters });
setRecords(resources.records || []);
setSubject(resources.subject || null);
setViews(resources.views || []);
......@@ -173,7 +197,7 @@ export default function SubjectRecordWorkbench({
} finally {
setLoading(false);
}
}, [subjectName, query]);
}, [activeViewName, filters, subjectName, query]);
useEffect(() => {
reload();
......@@ -287,21 +311,7 @@ export default function SubjectRecordWorkbench({
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
}, [filteredRecords]);
const viewCounts = useMemo(
() => buildViewCountMap(records, filters, gridViews, subject),
[records, filters, gridViews, subject],
);
const filterSchema = useMemo(() => {
return [
{
key: 'keyword',
label: '关键词搜索',
type: 'text',
icon: 'search',
placeholder: '请输入关键词',
},
];
}, []);
const filterSchema = useMemo(() => buildFilterSchema(visibleColumns), [visibleColumns]);
const feedbackHelpers = useMemo(() => ({
reload,
......@@ -463,22 +473,24 @@ export default function SubjectRecordWorkbench({
<>
<RecordWorkbench
summaryItems={summaryItems}
views={gridViews.map((view) => ({ ...view, count: viewCounts[view.name] || 0 }))}
views={gridViews}
activeViewName={activeView?.name || ''}
onViewChange={(viewName) => {
setActiveViewName(viewName);
setFilters({});
setPendingFilters({});
setSelectedIds([]);
setPage(0);
}}
filters={filters}
filters={pendingFilters}
filterSchema={filterSchema}
onFiltersChange={(key, value) => {
setFilters((current) => ({ ...current, [key]: value }));
setPage(0);
setSelectedIds([]);
setPendingFilters((current) => ({ ...current, [key]: value }));
}}
onQuery={() => { setFilters(pendingFilters); setPage(0); setSelectedIds([]); }}
onClearFilters={() => {
setFilters({ keyword: '' });
setFilters({});
setPendingFilters({});
setPage(0);
setSelectedIds([]);
}}
......@@ -549,6 +561,7 @@ export default function SubjectRecordWorkbench({
subject={subject}
subjectName={subjectName}
record={editingRecord}
view={activeView}
/>
<XinyuanModal
......
......@@ -14,7 +14,6 @@ import {
Tag,
TagGroup,
} from '@heroui/react';
import { useState } from 'react';
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
......@@ -23,12 +22,12 @@ function defaultFilter(textValue, inputValue) {
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
const keys = selectedKeys === 'all' ? ['all'] : selectedKeys || [];
return new Set([...keys].map((key) => String(key)));
if (selectedKeys === 'all') return new Set();
return new Set(Array.from(selectedKeys || []).map(toSelectionKey).filter(Boolean));
}
return new Set(
selectedKey === null || selectedKey === undefined ? [] : [String(selectedKey)],
selectedKey === null || selectedKey === undefined ? [] : [toSelectionKey(selectedKey)].filter(Boolean),
);
}
......@@ -38,6 +37,7 @@ function toSelectionKey(value) {
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
allowsEmptyCollection = false,
className,
emptyText = '暂无可选项',
errorText = '',
......@@ -66,12 +66,7 @@ export default function XinyuanAutocomplete({
valueText,
variant,
}) {
const [internalInputValue, setInternalInputValue] = useState('');
const resolvedInputValue = inputValue ?? internalInputValue;
const handleInputChange = (nextValue) => {
setInternalInputValue(nextValue);
onInputChange?.(nextValue);
};
const resolvedInputValue = inputValue ?? '';
const selectedKeySet = getSelectedKeySet(
selectionMode,
selectedKey,
......@@ -80,34 +75,12 @@ export default function XinyuanAutocomplete({
const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey),
);
const optionMap = new Map(
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 selectedOptions = options.filter((option) => selectedKeyStringSet.has(toSelectionKey(option.id)));
const resolvedValueText =
valueText ??
(selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: 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 visibleOptions = isLoading || errorText
? []
......@@ -152,18 +125,32 @@ export default function XinyuanAutocomplete({
);
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 (
<Autocomplete
{...rootOpenProps}
{...rootSelectionProps}
{...normalizedSelectionProps}
aria-label={ariaLabel || placeholder}
allowsEmptyCollection={allowsEmptyCollection}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
name={name}
selectionMode={selectionMode}
selectionMode={selectionMode === 'multiple' ? 'single' : selectionMode}
variant={variant}
onClear={handleClear}
onOpenChange={onOpenChange}
......@@ -219,7 +206,7 @@ export default function XinyuanAutocomplete({
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={resolvedInputValue}
onInputChange={handleInputChange}
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField
......@@ -245,7 +232,7 @@ export default function XinyuanAutocomplete({
]}
items={visibleOptions}
slot={null}
{...listBoxSelectionProps}
{...normalizedListProps}
renderEmptyState={() => (
<div
className="grid min-h-20 place-items-center px-3 py-4"
......
......@@ -21,6 +21,8 @@ import {
} from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect';
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 {
Alert,
......@@ -909,19 +911,35 @@ export default function RecordWorkbench({
placeholder={field.placeholder || `全部${field.label}`}
variant={filterFieldVariant}
/>
) : field.type === 'month' ? (
) : field.type === 'date' || field.type === 'datetime' || field.type === 'month' ? (
<FormDatePicker
aria-label={field.label}
className="w-full"
mode="month"
mode={field.type === 'month' ? 'month' : field.type === 'datetime' ? 'datetime' : 'date'}
type={field.type === 'datetime' ? 'datetime-local' : 'date'}
variant={filterFieldVariant}
value={filters[field.key] || ''}
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
className="w-full"
type="text"
type={field.type === 'number' ? 'number' : 'text'}
value={filters[field.key] || ''}
onChange={(event) => onFiltersChange?.(field.key, event.target.value)}
placeholder={field.placeholder}
......
......@@ -68,6 +68,13 @@
### 2.1 Subject View 授权
#### 2.1.1 通用列表与新增记录交互
- 列表字段、字段标题和筛选项均从当前 Grid View 配置生成;切换视图会清空当前筛选和勾选状态。
- 筛选控件按视图字段类型生成,引用和用户字段通过远程搜索选择,查询提交后按字段值向 `/record/query` 发送远程 `filter`;列表不再提供关键字搜索。
- 新建记录弹窗只渲染当前视图配置的字段,包含视图显式配置的 `title` 字段;标题是否显示及是否必填遵循视图配置,业务字段仍按 Subject 定义写入 `metadata`。
- 列表加载期间 loading 指示器在表格区域居中显示;查询失败保留错误状态,不提交成功反馈。
- Grid View 的 `settings.viewAuth` 只控制视图可见性。普通用户只读取
`user.applications[]` 中 `name = "integrity_risk_prevention_new"` 的 groups、roles 和
permissions;缺少配置默认拒绝。系统管理员、系统账号和该应用自身“管理”角色可旁路。
......
......@@ -6,17 +6,80 @@ import {
} from '@/services/DataService';
import {
buildViewColumns,
buildViewSearchColumns,
checkViewCondition,
formatFieldValue,
getFieldValue,
isViewAccessible,
normalizeText,
recordMatchesRestrictions,
sortRecordsByView,
toArray,
} from '@/utils/subjectView';
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) {
return {
......@@ -57,7 +120,7 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
return records;
}
export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
export async function loadSubjectWorkbenchResources(subjectName, query = {}, options = {}) {
const [subject, viewSummaries] = await Promise.all([
loadSubject(subjectName),
getSubjectView(subjectName),
......@@ -79,8 +142,12 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
}),
);
const accessibleGridViews = buildGridViews(views);
const records = accessibleGridViews.length > 0
? await queryAllSubjectRecords(subjectName, query)
const activeView = options.view || accessibleGridViews.find((view) => view.name === options.viewName) || accessibleGridViews[0];
const records = activeView
? await queryAllSubjectRecords(subjectName, {
...query,
filter: buildRemoteFilter(activeView, options.filters, query?.filter, subject),
})
: [];
return {
......@@ -97,9 +164,7 @@ export function buildGridViews(views = []) {
}
export function buildConfiguredWorkbenchView(records, filters, view, subject) {
const keyword = normalizeText(filters?.keyword).toLowerCase();
const columns = buildViewColumns(view, subject);
const searchColumns = buildViewSearchColumns(view, subject);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
......@@ -119,16 +184,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return false;
}
if (!keyword) {
return true;
}
const searchText = searchColumns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ')
.toLowerCase();
return searchText.includes(keyword);
return true;
});
return {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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