Commit a5ce9abc authored by drigle's avatar drigle

feat: sync view-driven record workbench

parent 508a8e17
---
name: subject-schema-manager
description: Sync Stream Subject definitions from the API into project documentation, remove unreferenced object sections, and apply confirmed object/field/configuration changes through the documented Subject API.
---
# Subject Schema Manager
Use this skill when the user asks to synchronize Subject/object documentation or asks the AI to create, update, or configure Stream Subjects and fields.
## Source of truth
- The live Subject API is authoritative for object and field structure.
- Read the repository's `SUBJECT_API.md`, `SUBJECT_CONFIGURATION.md`, `record-api.md`, and `subjectsDefinition.md` before acting. Prefer `src/services/record-api.md` and `src/services/subjectsDefinition.md` when the files exist there.
- `subjectsDefinition.md` is a versioned, human-readable snapshot generated from the API. It is not a runtime API client.
- Form resources are managed by `/form`; do not treat a `form` field as a Subject dependency.
## Modes
### `sync`
Use for requests to update object documentation or remove copied-project objects.
1. Discover Subject roots from static Subject constants and literal Subject API calls in the project's source. Prefer explicit roots supplied by the user or the script's `--roots` option when discovery is ambiguous.
2. Exchange the supplied ticket for a token using `POST /auth/login` with `{ "type": "ticket", "ticket": "..." }`. Never put the ticket or token in a file, generated Markdown, or normal command output.
3. Fetch each root with `GET /subject/{subject}`. Recursively follow every non-empty `field.settings.subject` on `reference`, `subject`, or other fields. Stop at cycles after documenting the object once.
4. Rebuild only the managed object-definition block in `subjectsDefinition.md`. The block must contain the root objects, every recursively referenced object, complete field properties, and dependency edges. Object sections outside the current dependency closure are removed from the managed block.
5. Do not mutate remote objects in this mode. A missing/ambiguous `settings.subject` is documented as unresolved; never invent a target object.
Run the deterministic helper from the project root:
```bash
node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync \
--project "$PWD" --ticket-stdin
```
Use `--check` for a read-only diff. The helper discovers the API base URL from `--base-url`, `STREAMS_API_URL`, or an absolute proxy target in `.umirc.ts`.
### `apply`
Use for requests that create objects, create/update fields, or update object configuration.
1. Inspect the API docs and produce an exact operation plan containing object names, field aliases, payloads, and dependency order. Include a concise human-readable summary and ask for explicit confirmation before any remote mutation.
2. After confirmation, write the plan as JSON outside the repository (for example `/tmp/subject-schema-plan.json`) and run the helper with `--confirmed --plan-file`. Pass the ticket via `SUBJECT_TICKET` or `--ticket-stdin`; never hardcode it.
3. The helper validates aliases and payloads, previews new objects with `POST /subject?preview=true`, then executes only the bounded operations in the plan:
- object create: `POST /subject`
- object update: `PUT /subject/{subject}`
- field create: `POST /subject/{subject}/field`
- field update: `PUT /subject/{subject}/field/{field}`
- field import/reorder when explicitly present in the plan
4. Remote object/field deletion is never implied by documentation cleanup. It requires explicit user intent and the helper's destructive-operation flag.
5. After successful mutations, re-fetch the complete dependency closure and run the same documentation sync using the authenticated token. Report successes and any partial-failure operation; there is no automatic rollback.
Run:
```bash
node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs apply \
--project "$PWD" --plan-file /tmp/subject-schema-plan.json \
--ticket-stdin --confirmed
```
The plan format is documented in [references/plan-schema.md](references/plan-schema.md). Do not use a generic arbitrary-URL request list; keep mutations within the documented Subject operations.
## Safety and documentation rules
- A plan confirmation authorizes only the listed operations. Do not add inferred fields, dependencies, or destructive operations after confirmation.
- Validate `select.settings.options`, `reference/subject settings.subject`, `required`, `multiple`, and `primary` against the API response or the confirmed plan.
- Use IDs for `reference` and `user` values, ISO 8601 for dates, and preserve `form` values as `{ form, data, settings? }`.
- If the API or local docs conflict, stop before mutation and show the exact conflict.
- If a business workflow or state rule changes, update the module's business-flow document separately; schema synchronization alone does not authorize workflow changes.
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 a JSON file containing only the explicitly approved Subject API operations. Keep
the file outside the repository when it contains environment-specific values.
```json
{
"version": 1,
"operations": [
{
"op": "create_subject",
"subject": {
"name": "customers",
"title": "Customers",
"type": "normal",
"access": "public",
"fields": [],
"views": []
}
},
{
"op": "update_subject",
"subject": "orders",
"patch": {"title": "Orders", "history": true}
},
{
"op": "create_field",
"subject": "orders",
"field": {
"name": "customer",
"label": "Customer",
"type": "reference",
"required": false,
"multiple": false,
"settings": {"subject": "customers"}
}
},
{
"op": "update_field",
"subject": "orders",
"field": "status",
"patch": {
"label": "Order status",
"settings": {"options": [{"label": "Open", "value": "open"}]}
}
},
{
"op": "import_fields",
"subject": "orders",
"fields": [{"name": "amount", "label": "Amount", "type": "number"}]
},
{
"op": "reorder_fields",
"subject": "orders",
"fields": ["order_no", "customer", "status", "amount"]
}
]
}
```
Supported operation names 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 `-`. `update_subject.patch` and `update_field.patch`
must contain only the properties supported by the corresponding API endpoints in
`SUBJECT_API.md`; the helper rejects arbitrary URLs or methods.
Creation of an object is always sent to `POST /subject?preview=true` before any mutating request.
Deletion operations are accepted only with the command-line flag `--allow-remote-delete` in
addition to `--confirmed`. A deletion may set `force: true` and `confirm` where the API requires
it; `confirm` must exactly equal the alias being deleted. Documentation synchronization alone
never creates a deletion plan.
#!/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;
});
...@@ -105,6 +105,12 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守: ...@@ -105,6 +105,12 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守:
- 流程文档更新内容至少应包含:功能入口、关键交互步骤、状态变化、核心字段/筛选条件、异常处理或限制条件。 - 流程文档更新内容至少应包含:功能入口、关键交互步骤、状态变化、核心字段/筛选条件、异常处理或限制条件。
- 提交结果时,除了说明改动代码,也要简要说明同步更新了哪个流程文档、补充了哪部分逻辑。 - 提交结果时,除了说明改动代码,也要简要说明同步更新了哪个流程文档、补充了哪部分逻辑。
## Subject Schema Skill
- 项目内置 `.agents/skills/subject-schema-manager/` 用于从 Subject API 同步 `src/services/subjectsDefinition.md`,并按已确认计划创建、更新或删除 Subject 与字段。
- 同步文档使用 `node .agents/skills/subject-schema-manager/scripts/subject_schema_manager.mjs sync --project "$PWD" --ticket-stdin`;远程变更必须先展示计划并在确认后使用 `apply --plan-file ... --confirmed`。
- ticket 仅允许通过 `SUBJECT_TICKET` 或标准输入传入,不得写入仓库、计划文件或普通日志。删除对象或字段必须显式提供 destructive 操作和确认值。
## 获取当前登录用户信息 ## 获取当前登录用户信息
可使用 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": [] } ] }
# 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 = '暂无数据' }) { ...@@ -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>
); );
} }
...@@ -217,7 +215,7 @@ export function DataTable({ ...@@ -217,7 +215,7 @@ export function DataTable({
className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')} className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}
> >
<div className="relative min-w-0 w-full"> <div className="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="数据加载中"
......
...@@ -16,6 +16,22 @@ function normalizeText(value) { ...@@ -16,6 +16,22 @@ function normalizeText(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
function normalizeRecordId(value) {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'object') {
const candidate = value._id ?? value.id ?? value.$oid ?? value.name;
if (candidate === value) {
return '';
}
return normalizeRecordId(candidate);
}
return normalizeText(value);
}
function escapeMongoRegex(value) { function escapeMongoRegex(value) {
return normalizeText(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return normalizeText(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
...@@ -75,19 +91,11 @@ function resolveRecordId(record) { ...@@ -75,19 +91,11 @@ function resolveRecordId(record) {
} }
// Reference 写入协议:只接受 _id / id // Reference 写入协议:只接受 _id / id
return record.id || record._id; return normalizeRecordId(record.id ?? record._id);
} }
function resolveReferenceValueId(value) { function resolveReferenceValueId(value) {
if (!value) { return normalizeRecordId(value);
return '';
}
if (typeof value === 'object') {
return normalizeText(resolveRecordId(value));
}
return normalizeText(value);
} }
function resolveRecordSubjectName(record, fallbackSubject = '') { function resolveRecordSubjectName(record, fallbackSubject = '') {
...@@ -592,6 +600,7 @@ export default function ReferenceSearchSelect({ ...@@ -592,6 +600,7 @@ export default function ReferenceSearchSelect({
<XinyuanAutocomplete <XinyuanAutocomplete
{...selectionProps} {...selectionProps}
aria-label={placeholder} aria-label={placeholder}
allowsEmptyCollection
emptyText="无匹配结果" emptyText="无匹配结果"
errorText={error} errorText={error}
inputValue={query} inputValue={query}
......
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Label } from '@heroui/react';
import { loadUser, queryUsers } from '@/services/DataService';
import { normalizeUserLabel, toArray } from '@/utils/subjectView';
import { useEffect, useMemo, useState } from 'react';
function getUserId(user) {
return String(typeof user === 'object' ? user?._id || user?.id || user?.name || '' : user || '');
}
export default function UserSearchSelect({
value,
onChange,
multiple = false,
disabled = false,
placeholder = '搜索用户名或显示名',
ariaLabel = '选择用户',
variant = 'secondary',
}) {
const [keyword, setKeyword] = useState('');
const [users, setUsers] = useState([]);
const [profiles, setProfiles] = useState({});
const [loading, setLoading] = useState(false);
const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]);
useEffect(() => {
let cancelled = false;
setLoading(true);
const text = keyword.trim();
queryUsers(0, 30, text ? {
$or: [
{ username: { $regex: text, $options: 'i' } },
{ display_name: { $regex: text, $options: 'i' } },
],
} : {})
.then((result) => {
if (cancelled) return;
const nextUsers = result?.users || [];
setUsers(nextUsers);
setProfiles((current) => ({
...current,
...Object.fromEntries(nextUsers.map((user) => [getUserId(user), user])),
}));
})
.catch(() => {
if (!cancelled) setUsers([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [keyword]);
useEffect(() => {
const missing = selectedIds.filter((id) => !profiles[id]);
if (!missing.length) return undefined;
let cancelled = false;
Promise.all(missing.map((id) => loadUser(id).catch(() => null))).then((items) => {
if (cancelled) return;
setProfiles((current) => ({
...current,
...Object.fromEntries(items.filter(Boolean).map((user) => [getUserId(user), user])),
}));
});
return () => { cancelled = true; };
}, [profiles, selectedIds]);
const options = useMemo(() => {
const merged = new Map(users.map((user) => [getUserId(user), user]));
selectedIds.forEach((id) => merged.set(id, profiles[id] || { _id: id, username: id }));
return [...merged.values()].map((user) => ({
id: getUserId(user),
label: normalizeUserLabel(user),
description: user.username || getUserId(user),
}));
}, [profiles, selectedIds, users]);
const valueText = selectedIds
.map((id) => normalizeUserLabel(profiles[id] || users.find((user) => getUserId(user) === id) || id))
.join('、');
return (
<XinyuanAutocomplete
aria-label={ariaLabel}
inputValue={keyword}
isDisabled={disabled}
isLoading={loading}
options={options}
placeholder={placeholder}
selectionMode={multiple ? 'multiple' : 'single'}
selectedKey={multiple ? undefined : selectedIds[0] || null}
selectedKeys={multiple ? new Set(selectedIds) : undefined}
shouldFilter={false}
valueText={valueText || placeholder}
variant={variant}
onInputChange={setKeyword}
onSelectionChange={(selection) => {
if (multiple) {
onChange?.(selection === 'all' ? options.map((option) => option.id) : [...selection]);
} else {
onChange?.(selection ? String(selection) : null);
}
}}
renderOption={(option) => (
<Label className="block min-w-0 truncate">{option.label}</Label>
)}
/>
);
}
...@@ -20,10 +20,22 @@ function defaultFilter(textValue, inputValue) { ...@@ -20,10 +20,22 @@ function defaultFilter(textValue, inputValue) {
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) { function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') { if (selectionMode === 'multiple') {
return new Set(selectedKeys || []); if (selectedKeys === 'all') {
return new Set();
} }
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]); return new Set(
Array.from(selectedKeys || [])
.map(toSelectionKey)
.filter(Boolean),
);
}
return new Set(
selectedKey === null || selectedKey === undefined
? []
: [toSelectionKey(selectedKey)].filter(Boolean),
);
} }
function toSelectionKey(value) { function toSelectionKey(value) {
...@@ -32,6 +44,7 @@ function toSelectionKey(value) { ...@@ -32,6 +44,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 = '',
...@@ -76,54 +89,58 @@ export default function XinyuanAutocomplete({ ...@@ -76,54 +89,58 @@ export default function XinyuanAutocomplete({
(selectedOptions.length > 0 (selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、') ? selectedOptions.map((option) => option.label).join('、')
: placeholder); : placeholder);
const rootSelectionProps = const isMultiple = selectionMode === 'multiple';
selectionMode === 'multiple' const normalizedSelectedKeys = selectedKeySet;
? { selectedKeys } const rootSelectionProps = isMultiple
: { selectedKey }; ? { selectedKey: null }
const listBoxSelectionProps = : { selectedKey: normalizedSelectedKeys.values().next().value || null };
selectionMode === 'multiple' const handleMultipleOptionAction = (optionKey) => {
? { if (!onSelectionChange) {
selectionMode,
selectedKeys,
}
: {};
const visibleOptions = isLoading || errorText ? [] : options;
const handleMultipleOptionPress = (option) => {
if (
selectionMode !== 'multiple' ||
!onSelectionChange ||
option?.isDisabled
) {
return; return;
} }
const optionKey = toSelectionKey(option.id); const normalizedOptionKey = toSelectionKey(optionKey);
if (!optionKey) { if (!normalizedOptionKey) {
return; return;
} }
const nextKeys = new Set( const nextKeys = new Set(normalizedSelectedKeys);
Array.from(selectedKeyStringSet).filter(Boolean), if (nextKeys.has(normalizedOptionKey)) {
); nextKeys.delete(normalizedOptionKey);
if (nextKeys.has(optionKey)) {
nextKeys.delete(optionKey);
} else { } else {
nextKeys.add(optionKey); nextKeys.add(normalizedOptionKey);
} }
onSelectionChange(nextKeys); onSelectionChange(nextKeys);
}; };
const listBoxSelectionProps = isMultiple
? {
selectionMode: 'single',
onAction: handleMultipleOptionAction,
}
: {
selectionMode: 'single',
selectedKeys: normalizedSelectedKeys,
onSelectionChange,
};
const visibleOptions = isLoading || errorText
? []
: options.map((option) => ({
...option,
isSelected: selectedKeyStringSet.has(toSelectionKey(option.id)),
}));
return ( return (
<Autocomplete <Autocomplete
{...rootSelectionProps} {...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} isOpen={isOpen}
isRequired={isRequired} isRequired={isRequired}
name={name} name={name}
selectionMode={selectionMode} selectionMode={isMultiple ? 'single' : selectionMode}
variant={variant} variant={variant}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange} onSelectionChange={onSelectionChange}
...@@ -177,16 +194,14 @@ export default function XinyuanAutocomplete({ ...@@ -177,16 +194,14 @@ export default function XinyuanAutocomplete({
)} )}
> >
{(option) => { {(option) => {
const isSelected = selectedKeyStringSet.has(toSelectionKey(option.id)); const isSelected = Boolean(option.isSelected);
return ( return (
<ListBox.Item <ListBox.Item
id={option.id} id={option.id}
isDisabled={option.isDisabled} isDisabled={option.isDisabled}
isSelected={isSelected}
textValue={option.textValue || option.label} textValue={option.textValue || option.label}
{...(selectionMode === 'multiple'
? { onPress: () => handleMultipleOptionPress(option) }
: {})}
> >
{renderOption ? ( {renderOption ? (
renderOption(option) renderOption(option)
......
...@@ -22,6 +22,8 @@ import { ...@@ -22,6 +22,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,
...@@ -996,19 +998,35 @@ export default function RecordWorkbench({ ...@@ -996,19 +998,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}
...@@ -1173,6 +1191,16 @@ export default function RecordWorkbench({ ...@@ -1173,6 +1191,16 @@ export default function RecordWorkbench({
<Tooltip.Content>重置筛选</Tooltip.Content> <Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip> </Tooltip>
) : null} ) : null}
{onQuery ? (
<Button
isDisabled={loading}
size="sm"
type="submit"
variant="primary"
>
查询
</Button>
) : null}
</form> </form>
) : null} ) : null}
......
...@@ -103,6 +103,7 @@ export default function ReferenceSearchSelect({ ...@@ -103,6 +103,7 @@ export default function ReferenceSearchSelect({
<XinyuanAutocomplete <XinyuanAutocomplete
{...selectionProps} {...selectionProps}
aria-label="引用记录" aria-label="引用记录"
allowsEmptyCollection
emptyText="暂无可选记录" emptyText="暂无可选记录"
inputValue={keyword} inputValue={keyword}
options={autocompleteOptions} options={autocompleteOptions}
......
...@@ -80,6 +80,7 @@ import { ...@@ -80,6 +80,7 @@ import {
buildProblemRectificationConfiguredListView, buildProblemRectificationConfiguredListView,
buildProblemRectificationCreateFormFields, buildProblemRectificationCreateFormFields,
buildProblemRectificationCreatePayload, buildProblemRectificationCreatePayload,
buildProblemRectificationFilterSchema,
getProblemRectificationFlowTableUrl, getProblemRectificationFlowTableUrl,
getRecordCellText, getRecordCellText,
delayRequestItemsMatch, delayRequestItemsMatch,
...@@ -776,18 +777,8 @@ export default function ProblemRectificationListPage() { ...@@ -776,18 +777,8 @@ export default function ProblemRectificationListPage() {
const [recordsLoading, setRecordsLoading] = useState(false); const [recordsLoading, setRecordsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({});
keyword: '', const [pendingFilters, setPendingFilters] = useState({});
inspectionReportMonth: '',
progress: '',
auditStatus: '',
severityLevel: '',
overallRating: '',
primaryResponsibleDepartment: '',
problemFrom: '',
rootCause: '',
focusFlag: '',
});
const [activeViewName, setActiveViewName] = useState(''); const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
...@@ -1054,8 +1045,12 @@ export default function ProblemRectificationListPage() { ...@@ -1054,8 +1045,12 @@ export default function ProblemRectificationListPage() {
}, [reloadList]); }, [reloadList]);
const configuredView = useMemo( const configuredView = useMemo(
() => buildProblemRectificationConfiguredListView(records, filters, activeGridView, subject), () => buildProblemRectificationConfiguredListView(records, {}, activeGridView, subject),
[records, filters, activeGridView, subject], [records, activeGridView, subject],
);
const dynamicFilterSchema = useMemo(
() => buildProblemRectificationFilterSchema(configuredView.columns || []),
[configuredView.columns],
); );
const createFields = useMemo( const createFields = useMemo(
...@@ -1109,10 +1104,11 @@ export default function ProblemRectificationListPage() { ...@@ -1109,10 +1104,11 @@ export default function ProblemRectificationListPage() {
const handleCreate = async () => { const handleCreate = async () => {
if ( if (
!normalizeText(formValues.title) &&
!normalizeText(formValues.project_name) && !normalizeText(formValues.project_name) &&
!normalizeText(formValues.problem_brief) !normalizeText(formValues.problem_brief)
) { ) {
setCreateValidationMessage('检查项目名称、问题简述至少填写一项。'); setCreateValidationMessage('记录标题、检查项目名称、问题简述至少填写一项。');
return; return;
} }
...@@ -1740,85 +1736,24 @@ export default function ProblemRectificationListPage() { ...@@ -1740,85 +1736,24 @@ export default function ProblemRectificationListPage() {
setActiveViewName(viewName); setActiveViewName(viewName);
setPage(0); setPage(0);
setSelectedIds([]); setSelectedIds([]);
setFilters({});
setPendingFilters({});
}} }}
filters={filters} filters={pendingFilters}
filterSchema={[ filterSchema={dynamicFilterSchema}
{
key: 'keyword',
label: '关键词搜索',
type: 'text',
icon: 'search',
placeholder: '请输入关键词',
},
{
key: 'inspectionReportMonth',
label: '检查报告出具月份',
type: 'month',
},
{
key: 'progress',
label: '整改进度',
type: 'select',
placeholder: '全部进度',
options: configuredView.progressOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'auditStatus',
label: '审批状态',
type: 'select',
placeholder: '全部状态',
options: configuredView.auditStatusOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'severityLevel',
label: '严重程度',
type: 'select',
placeholder: '全部严重程度',
options: configuredView.severityOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'overallRating',
label: '整改评级',
type: 'select',
placeholder: '全部评级',
options: configuredView.overallRatingOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'primaryResponsibleDepartment',
label: '主责部门',
type: 'select',
placeholder: '全部主责部门',
options: configuredView.primaryResponsibleDepartmentOptions.map((option) => ({
label: option,
value: option,
})),
},
{
key: 'problemFrom',
label: '问题来源',
type: 'select',
placeholder: '全部来源',
options: configuredView.problemFromOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'rootCause',
label: '问题原因',
type: 'select',
placeholder: '全部原因',
options: configuredView.rootCauseOptions.map((option) => ({ label: option, value: option })),
},
{
key: 'focusFlag',
label: '重点关注',
type: 'select',
placeholder: '全部类型',
options: configuredView.focusOptions,
},
]}
onFiltersChange={(key, value) => { onFiltersChange={(key, value) => {
setPendingFilters((current) => ({ ...current, [key]: value }));
}}
onClearFilters={() => {
setPendingFilters({});
setFilters({});
setPage(0);
setSelectedIds([]);
}}
onQuery={() => {
setFilters(pendingFilters);
setPage(0); setPage(0);
setSelectedIds([]); setSelectedIds([]);
setFilters((current) => ({ ...current, [key]: value }));
}} }}
title="问题清单" title="问题清单"
columns={configuredView.columns || []} columns={configuredView.columns || []}
......
...@@ -56,12 +56,19 @@ ...@@ -56,12 +56,19 @@
### 3.3 视图配置驱动约定 ### 3.3 视图配置驱动约定
问题整改列表的新增表单、筛选和表格均读取当前激活 Grid View:
- 新增表单按 `settings.columns` 顺序渲染可见字段,并包含记录标题字段;标题是否必填跟随视图列配置,未配置短标题时使用标题作为默认回退值。
- 筛选控件由视图列的 Subject 字段类型自动生成。`select` 选项来自 `settings.options`,日期、数字、布尔、用户和引用字段使用对应控件;提交时按 `field:<path>` 通过 `/record/query` 远程筛选,不再提供独立关键词搜索。
- 引用和用户多选的输入摘要、列表勾选与草稿统一使用归一化 ID;引用写入只保存目标记录 `_id` 或 `_id[]`。
- 表格请求期间 DataGrid 使用居中的 loading 遮罩,空结果与加载中状态分开处理。
问题整改主列表与详情抽屉统一以 `getSubjectView(subjectName)` 返回的视图配置驱动,不再在页面内写死部门标签和字段顺序: 问题整改主列表与详情抽屉统一以 `getSubjectView(subjectName)` 返回的视图配置驱动,不再在页面内写死部门标签和字段顺序:
- `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab - `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab
- `settings.columns` 决定列表列顺序、宽度、固定列位置和排序优先级,列表页不得在视图列后追加自定义业务字段列 - `settings.columns` 决定列表列顺序、宽度、固定列位置和排序优先级,列表页不得在视图列后追加自定义业务字段列
- `settings.groups` 决定列表列分组,例如把多个问题分类字段归入同一分组;前端需展示分组表头,同一分组下的列即使在 `settings.columns` 中不连续,也应在表格内聚拢展示,并支持每个分组独立折叠 / 展开,折叠后隐藏该分组子列 - `settings.groups` 决定列表列分组,例如把多个问题分类字段归入同一分组;前端需展示分组表头,同一分组下的列即使在 `settings.columns` 中不连续,也应在表格内聚拢展示,并支持每个分组独立折叠 / 展开,折叠后隐藏该分组子列
- `settings.shortTitle = true` 仅隐藏列表中的 `title` 展示列,不删除记录标题数据,也不改变标题字段参与的关键词查询、视图排序、详情标题或权限判断;标题列原本承载的部门明细数量 / 展开、延期申请阶段 / 详情和锁定状态迁移到固定的“问题动态”上下文列,详情、延期申请、锁定、复制和删除继续由固定行操作列提供 - `settings.shortTitle = true` 仅隐藏列表中的 `title` 展示列,不删除记录标题数据,也不改变标题字段的视图排序、详情标题或权限判断;标题列原本承载的部门明细数量 / 展开、延期申请阶段 / 详情和锁定状态迁移到固定的“问题动态”上下文列,详情、延期申请、锁定、复制和删除继续由固定行操作列提供
- `settings.quickSelect` 决定列表是否展示勾选列,用于批量推送、删除等操作 - `settings.quickSelect` 决定列表是否展示勾选列,用于批量推送、删除等操作
- `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围 - `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围
- Grid 视图的 `settings.viewAuth` 决定当前用户是否能看到该视图;普通用户未配置 `viewAuth` 时默认不可见,仅系统管理员和问题整改应用 `problem_rectification_new` 自身的“管理”角色可见。全部 Grid 视图均无权限时不得生成绕过授权的兜底视图、查询记录或展示新增/批量/行操作入口 - Grid 视图的 `settings.viewAuth` 决定当前用户是否能看到该视图;普通用户未配置 `viewAuth` 时默认不可见,仅系统管理员和问题整改应用 `problem_rectification_new` 自身的“管理”角色可见。全部 Grid 视图均无权限时不得生成绕过授权的兜底视图、查询记录或展示新增/批量/行操作入口
...@@ -78,7 +85,7 @@ ...@@ -78,7 +85,7 @@
- `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器 - `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作 - 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、筛选条件、关键词和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size` - `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、视图字段筛选条件和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size`
- 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底 - 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底
- 主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。 - 主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。
- 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数 - 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数
......
...@@ -13,13 +13,15 @@ import { ...@@ -13,13 +13,15 @@ import {
import { calculateProblemSeverityLevel } from './problemSeverity'; import { calculateProblemSeverityLevel } from './problemSeverity';
import { import {
buildViewColumns, buildViewColumns,
buildViewSearchColumns,
checkViewCondition, checkViewCondition,
emptyValueForField, emptyValueForField,
formatFieldValue, formatFieldValue,
getFieldNameFromPath, getFieldNameFromPath,
getFieldValue, getFieldValue,
getSelectOptions, getSelectOptions,
getReferenceSubjectName,
isDateFieldType,
isReferenceFieldType,
isViewAccessible, isViewAccessible,
normalizeValueForSubmit, normalizeValueForSubmit,
normalizeText, normalizeText,
...@@ -767,26 +769,93 @@ function buildViewConditionsFilter(filters = {}) { ...@@ -767,26 +769,93 @@ function buildViewConditionsFilter(filters = {}) {
return combineFilterClauses(nextClauses); return combineFilterClauses(nextClauses);
} }
function buildKeywordFilter(keyword, columns = []) { function normalizeDynamicFilterField(field) {
const text = normalizeText(keyword); const normalized = normalizeText(field);
if (!text) { if (!normalized || normalized === 'title' || normalized.startsWith('metadata.')) {
return {}; return normalized;
} }
return `metadata.${normalized}`;
}
const fields = Array.from( function escapeDynamicFilterRegex(value) {
new Set([ return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
'title', }
...columns
.map((column) => column.field)
.filter((field) => field && field !== 'title'),
]),
);
function buildDynamicFilterClause(field, value, fieldDef) {
if (isEmptyFilterValue(value)) return null;
const fieldPath = normalizeDynamicFilterField(field);
const type = normalizeText(fieldDef?.type || 'text').toLowerCase();
if (isDateFieldType(type)) {
const text = normalizeText(value).slice(0, 10);
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return { return {
$or: fields.map((field) => ({ [fieldPath]: {
[field]: { $regex: text, $options: 'i' }, $gte: `${text}T00:00:00.000Z`,
})), $lt: `${text}T23:59:59.999Z`,
},
}; };
}
}
if (type === 'number') {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? { [fieldPath]: numberValue } : null;
}
if (type === 'boolean') {
if (value === true || value === false) return { [fieldPath]: value };
if (value === 'true' || value === 'false') return { [fieldPath]: value === 'true' };
return null;
}
const values = toArray(value).map((item) => normalizeText(item)).filter(Boolean);
if (values.length === 0) return null;
if (isReferenceFieldType(type) || type === 'select') {
return fieldDef?.multiple
? { [fieldPath]: { $in: values } }
: { [fieldPath]: values[0] };
}
return {
[fieldPath]: {
$regex: values.map(escapeDynamicFilterRegex).join('|'),
$options: 'i',
},
};
}
export function buildProblemRectificationFilterSchema(columns = []) {
return columns.reduce((fields, column) => {
const field = column?.field;
const fieldDef = column?.fieldDef || {};
if (!field) return fields;
const type = normalizeText(fieldDef.type || 'text').toLowerCase();
let controlType = 'text';
let options;
if (type === 'select') {
options = getSelectOptions(fieldDef);
controlType = options.length ? 'select' : 'text';
} else if (type === 'boolean') {
controlType = 'select';
options = [{ label: '是', value: 'true' }, { label: '否', value: 'false' }];
} else if (isDateFieldType(type)) {
controlType = 'date';
} else if (type === 'number') {
controlType = 'number';
} else if (type === 'user') {
controlType = 'user';
} else if (isReferenceFieldType(type)) {
controlType = getReferenceSubjectName(fieldDef) ? 'reference' : 'text';
} else if (!['text', 'long_text', 'rich_text'].includes(type)) {
return fields;
}
fields.push({
key: `field:${field}`,
field,
fieldDef,
label: column.label || field,
type: controlType,
options,
placeholder: `筛选${column.label || field}`,
});
return fields;
}, []);
} }
function buildInspectionReportMonthFilter(month) { function buildInspectionReportMonthFilter(month) {
...@@ -973,7 +1042,13 @@ export function buildProblemRectificationQueryFilter(filters = {}, gridView, sub ...@@ -973,7 +1042,13 @@ export function buildProblemRectificationQueryFilter(filters = {}, gridView, sub
buildProblemSeverityFilter(filters?.severityLevel), buildProblemSeverityFilter(filters?.severityLevel),
buildCalculatedQualityRatingFilter(filters?.overallRating), buildCalculatedQualityRatingFilter(filters?.overallRating),
buildFocusFlagFilter(filters?.focusFlag), buildFocusFlagFilter(filters?.focusFlag),
buildKeywordFilter(filters?.keyword, columns), ...Object.entries(filters || {})
.filter(([key]) => key.startsWith('field:'))
.map(([key, value]) => buildDynamicFilterClause(
key.slice('field:'.length),
value,
columns.find((column) => column.field === key.slice('field:'.length))?.fieldDef,
)),
]); ]);
} }
...@@ -1235,30 +1310,9 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -1235,30 +1310,9 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
}; };
} }
const nextKeyword = filters?.keyword || '';
const nextInspectionReportMonth = normalizeText(filters?.inspectionReportMonth);
const nextProgress = filters?.progress || '';
const nextAuditStatus = filters?.auditStatus || '';
const nextSeverityLevel = filters?.severityLevel || '';
const nextOverallRating = filters?.overallRating || '';
const nextPrimaryResponsibleDepartment = filters?.primaryResponsibleDepartment || '';
const nextProblemFrom = filters?.problemFrom || '';
const nextRootCause = filters?.rootCause || '';
const nextFocusFlag = filters?.focusFlag || '';
const columns = buildViewColumnsForList(gridView, subject); const columns = buildViewColumnsForList(gridView, subject);
const searchColumns = buildViewSearchColumns(gridView, subject);
const searchableFields = searchColumns.map((column) => column.field);
const filteredRecords = records.filter((record) => { const filteredRecords = records.filter((record) => {
if (
nextInspectionReportMonth &&
!record.inspectionReportDate.startsWith(`${nextInspectionReportMonth}-`) &&
record.inspectionReportDate !== nextInspectionReportMonth
) {
return false;
}
const rawRecord = record.raw || record; const rawRecord = record.raw || record;
if (!recordMatchesRestrictions(rawRecord, gridView?.settings?.restrictions)) { if (!recordMatchesRestrictions(rawRecord, gridView?.settings?.restrictions)) {
...@@ -1277,85 +1331,7 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -1277,85 +1331,7 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
return false; return false;
} }
if (nextProgress && record.progress !== nextProgress) {
return false;
}
if (nextAuditStatus && record.auditStatus !== nextAuditStatus) {
return false;
}
if (nextSeverityLevel && record.severityLevel !== nextSeverityLevel) {
return false;
}
if (nextOverallRating && record.overallRating !== nextOverallRating) {
return false;
}
if (
nextPrimaryResponsibleDepartment &&
record.primaryResponsibleDepartment !== nextPrimaryResponsibleDepartment
) {
return false;
}
if (nextProblemFrom && record.problemFrom !== nextProblemFrom) {
return false;
}
if (nextRootCause && record.rootCause !== nextRootCause) {
return false;
}
if (nextFocusFlag === 'consequence') {
if (
!record.hasEconomicLoss &&
!record.hasExternalImpact &&
!record.hasEmployeeAccountability
) {
return false;
}
}
if (nextFocusFlag === 'high_severity' && record.severityLevel !== '高') {
return false;
}
if (nextFocusFlag === 'low_rating' && record.overallRating !== '低') {
return false;
}
if (
nextFocusFlag === 'repeated_or_historical' &&
!record.isHistoricalProblem &&
!record.isRepeatedProblem
) {
return false;
}
if (nextFocusFlag === 'overdue' && !record.isOverdue) {
return false;
}
if (
nextFocusFlag === 'policy_or_system' &&
!record.involvesPolicyRevision &&
!record.involvesSystemConstruction
) {
return false;
}
if (!nextKeyword) {
return true; return true;
}
const keyword = normalizeText(nextKeyword).toLowerCase();
return searchableFields
.map((fieldPath) => normalizeText(formatFieldValue(getFieldValue(rawRecord, fieldPath), searchColumns.find((column) => column.field === fieldPath)?.fieldDef)))
.join(' ')
.toLowerCase()
.includes(keyword);
}); });
const sortedRecords = sortRecordsByView(filteredRecords, gridView); const sortedRecords = sortRecordsByView(filteredRecords, gridView);
...@@ -1507,8 +1483,16 @@ export function buildProblemRectificationListView(records = [], filters = {}) { ...@@ -1507,8 +1483,16 @@ export function buildProblemRectificationListView(records = [], filters = {}) {
} }
export function buildProblemRectificationCreateFormFields(gridView, subject) { export function buildProblemRectificationCreateFormFields(gridView, subject) {
const columns = buildViewColumnsForList(gridView, subject) const allColumns = buildViewColumns(gridView, subject, { includeHidden: true });
const titleColumn = allColumns.find((column) => column.field === 'title' && !column.hide) ||
(gridView?.settings?.shortTitle === true ? null : {
field: 'title',
label: '记录标题',
fieldDef: { name: 'title', label: '记录标题', type: 'text', required: false },
});
const columns = allColumns
.filter((column) => column.field !== 'title') .filter((column) => column.field !== 'title')
.filter((column) => !column.hide)
.filter((column) => column.field?.startsWith('metadata.')) .filter((column) => column.field?.startsWith('metadata.'))
.filter((column) => column.fieldDef?.name) .filter((column) => column.fieldDef?.name)
.filter( .filter(
...@@ -1528,9 +1512,11 @@ export function buildProblemRectificationCreateFormFields(gridView, subject) { ...@@ -1528,9 +1512,11 @@ export function buildProblemRectificationCreateFormFields(gridView, subject) {
return true; return true;
}); });
if (fields.length > 0) { const hasConfiguredColumns = toArray(gridView?.settings?.columns).length > 0;
if (fields.length > 0 || hasConfiguredColumns) {
const nextFields = [...fields]; const nextFields = [...fields];
if (!hasConfiguredColumns) {
WORKBENCH_PRIORITY_CREATE_FIELD_NAMES.forEach((fieldName) => { WORKBENCH_PRIORITY_CREATE_FIELD_NAMES.forEach((fieldName) => {
if (seen.has(fieldName)) { if (seen.has(fieldName)) {
return; return;
...@@ -1548,15 +1534,37 @@ export function buildProblemRectificationCreateFormFields(gridView, subject) { ...@@ -1548,15 +1534,37 @@ export function buildProblemRectificationCreateFormFields(gridView, subject) {
fieldDef, fieldDef,
}); });
}); });
}
return nextFields; return titleColumn
? [{
...titleColumn,
fieldDef: {
name: 'title',
label: titleColumn.label || '记录标题',
type: 'text',
required: Boolean(titleColumn.required || titleColumn.isRequired || titleColumn.is_required),
},
}, ...nextFields]
: nextFields;
} }
return (subject?.fields || []).map((field) => ({ const fallbackFields = (subject?.fields || []).map((field) => ({
field: `metadata.${field.name}`, field: `metadata.${field.name}`,
label: field.label || field.name, label: field.label || field.name,
fieldDef: field, fieldDef: field,
})); }));
return titleColumn
? [{
...titleColumn,
fieldDef: {
name: 'title',
label: titleColumn.label || '记录标题',
type: 'text',
required: Boolean(titleColumn.required || titleColumn.isRequired || titleColumn.is_required),
},
}, ...fallbackFields]
: fallbackFields;
} }
export function buildInitialCreateValues(createFields) { export function buildInitialCreateValues(createFields) {
...@@ -1578,6 +1586,10 @@ export function buildProblemRectificationCreatePayload(formValues, createFields ...@@ -1578,6 +1586,10 @@ export function buildProblemRectificationCreatePayload(formValues, createFields
return nextMetadata; return nextMetadata;
} }
if (fieldName === 'title') {
return nextMetadata;
}
return { return {
...nextMetadata, ...nextMetadata,
[fieldName]: normalizeValueForSubmit(formValues[fieldName], field.fieldDef), [fieldName]: normalizeValueForSubmit(formValues[fieldName], field.fieldDef),
...@@ -1586,10 +1598,11 @@ export function buildProblemRectificationCreatePayload(formValues, createFields ...@@ -1586,10 +1598,11 @@ export function buildProblemRectificationCreatePayload(formValues, createFields
const projectName = normalizeText(metadata.project_name); const projectName = normalizeText(metadata.project_name);
const problemBrief = normalizeText(metadata.problem_brief); const problemBrief = normalizeText(metadata.problem_brief);
const recordTitle = normalizeText(formValues.title);
return { return {
subject: PROBLEM_RECTIFICATION_SUBJECT, subject: PROBLEM_RECTIFICATION_SUBJECT,
title: projectName || problemBrief || '问题整改记录', title: recordTitle || projectName || problemBrief || '问题整改记录',
metadata: { metadata: {
...metadata, ...metadata,
problem_severity_level: calculateProblemSeverityLevel(metadata), problem_severity_level: calculateProblemSeverityLevel(metadata),
......
...@@ -259,12 +259,16 @@ export function isDateFieldType(fieldType) { ...@@ -259,12 +259,16 @@ export function isDateFieldType(fieldType) {
} }
export function normalizeRecordId(value) { export function normalizeRecordId(value) {
if (!value) { if (value === null || value === undefined || value === '') {
return ''; return '';
} }
if (typeof value === 'object') { if (typeof value === 'object') {
return value._id || value.id || value.name || ''; const candidate = value._id ?? value.id ?? value.$oid ?? value.name;
if (candidate === value) {
return '';
}
return normalizeRecordId(candidate);
} }
return normalizeText(value); return normalizeText(value);
...@@ -498,7 +502,7 @@ export function sortRecordsByView(records, view) { ...@@ -498,7 +502,7 @@ export function sortRecordsByView(records, view) {
} }
export function getSelectOptions(fieldDef) { export function getSelectOptions(fieldDef) {
return toArray(fieldDef?.settings?.options || fieldDef?.options).map((option) => { return toArray(fieldDef?.settings?.options).map((option) => {
if (typeof option === 'string') { if (typeof option === 'string') {
return { label: option, value: option }; return { label: option, value: option };
} }
......
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