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.
......@@ -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": [] } ] }
This diff is collapsed.
# Object Configuration Migration
对象、字段、视图和 ticket 换 token 的完整 HTTP API 参考见 [SUBJECT_API.md](./SUBJECT_API.md)。本文保留配置包迁移的详细说明。
The object configuration package moves metadata between Streams environments. It does not move records, database IDs, object members, tags, categories, connector settings, credentials, or existing external database tables.
## Workflow
1. Select one or more normal, embedded, or external objects in Admin > Object Management and choose `Export Configuration`.
2. Referenced objects are included automatically. The selected objects are listed in `selected_subjects`; dependency objects follow them in `subjects`.
3. In the target environment choose `Import Configuration` and select the JSON file.
4. Review the precheck. Only a package with no blocking issues can be applied.
5. The import uses `upsert`: matching objects and fields are updated, new ones are created, and target-only fields and views are preserved.
For an external object, create and enable a DM8 connector in the target environment before import. Its stable `connector.name` must match the alias exported in `external.connector`. A new external object provisions a new managed table using the `dm_<subject.name>` convention; import never adopts, binds to, or overwrites a pre-existing physical table.
## Stable identifiers
The following identifiers are portable business codes and must not be replaced with database IDs:
- object: `subject.name`
- field: `field.name`
- select option: `field.settings.options[].value`
- reference target: `field.settings.subject`
- external connector: `subject.external.connector` (`connector.name`)
For example:
```json
{
"kind": "streams.subject-configuration",
"version": 1,
"selected_subjects": ["orders"],
"include_dependencies": true,
"subjects": [
{
"name": "orders",
"title": "Orders",
"type": "normal",
"fields": [
{
"name": "status",
"label": "Status",
"type": "select",
"settings": {
"options": [
{"label": "Open", "value": "open"}
]
}
},
{
"name": "customer",
"label": "Customer",
"type": "reference",
"settings": {"subject": "customers"}
}
],
"views": []
},
{
"name": "customers",
"title": "Customers",
"type": "normal",
"fields": [],
"views": []
}
]
}
```
An external object only exports its connector alias:
```json
{
"name": "external_orders",
"title": "External Orders",
"type": "external",
"external": {
"connector": "business_dm"
},
"fields": [],
"views": []
}
```
The package never contains the connector host, account, password, settings, runtime state, physical table metadata, or records.
## API
- `POST /subject/configuration/export` with `{subjects: ["orders"], include_dependencies: true}`
- `POST /subject/configuration/import/preview` with `{mode: "upsert", package: {...}}`
- `POST /subject/configuration/import` with `{mode: "upsert", package: {...}}`
The import order is normal object shells, complete new external objects, object properties, normal fields, reference fields, and views. A new external object is created once with its complete field definition so the managed table is provisioned once. This also allows circular references between objects to be imported after referenced object names exist.
External-object precheck blocks import when:
- `external.connector` is missing or invalid;
- the same-alias connector does not exist, is inactive, or is not DM8-compatible;
- the target table for a new external object already exists;
- an existing external object's connector alias differs from the package;
- an existing external object is not in the `ready` state;
- an external field change is not supported by the managed-table schema.
## Deliberate limitations in V1
- Records are never included.
- Connector settings and credentials are never included; target connectors are resolved by alias.
- Existing physical tables are never adopted or overwritten by configuration import.
- Members, tags, and categories are reported as warnings and are not imported.
- Upsert never deletes fields or views that only exist in the target environment.
......@@ -39,9 +39,7 @@ export function DataTableEmptyState({ title = '暂无数据' }) {
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" />
);
}
......@@ -217,7 +215,7 @@ export function DataTable({
className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}
>
<div className="relative min-w-0 w-full">
{isLoading && rows.length > 0 ? (
{isLoading ? (
<div
className="absolute inset-0 z-10 grid place-items-center"
aria-label="数据加载中"
......
......@@ -16,6 +16,22 @@ function normalizeText(value) {
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) {
return normalizeText(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
......@@ -75,19 +91,11 @@ function resolveRecordId(record) {
}
// Reference 写入协议:只接受 _id / id
return record.id || record._id;
return normalizeRecordId(record.id ?? record._id);
}
function resolveReferenceValueId(value) {
if (!value) {
return '';
}
if (typeof value === 'object') {
return normalizeText(resolveRecordId(value));
}
return normalizeText(value);
return normalizeRecordId(value);
}
function resolveRecordSubjectName(record, fallbackSubject = '') {
......@@ -592,6 +600,7 @@ export default function ReferenceSearchSelect({
<XinyuanAutocomplete
{...selectionProps}
aria-label={placeholder}
allowsEmptyCollection
emptyText="无匹配结果"
errorText={error}
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) {
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
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) {
......@@ -32,6 +44,7 @@ function toSelectionKey(value) {
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
allowsEmptyCollection = false,
className,
emptyText = '暂无可选项',
errorText = '',
......@@ -76,54 +89,58 @@ export default function XinyuanAutocomplete({
(selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const listBoxSelectionProps =
selectionMode === 'multiple'
? {
selectionMode,
selectedKeys,
}
: {};
const visibleOptions = isLoading || errorText ? [] : options;
const handleMultipleOptionPress = (option) => {
if (
selectionMode !== 'multiple' ||
!onSelectionChange ||
option?.isDisabled
) {
const isMultiple = selectionMode === 'multiple';
const normalizedSelectedKeys = selectedKeySet;
const rootSelectionProps = isMultiple
? { selectedKey: null }
: { selectedKey: normalizedSelectedKeys.values().next().value || null };
const handleMultipleOptionAction = (optionKey) => {
if (!onSelectionChange) {
return;
}
const optionKey = toSelectionKey(option.id);
if (!optionKey) {
const normalizedOptionKey = toSelectionKey(optionKey);
if (!normalizedOptionKey) {
return;
}
const nextKeys = new Set(
Array.from(selectedKeyStringSet).filter(Boolean),
);
if (nextKeys.has(optionKey)) {
nextKeys.delete(optionKey);
const nextKeys = new Set(normalizedSelectedKeys);
if (nextKeys.has(normalizedOptionKey)) {
nextKeys.delete(normalizedOptionKey);
} else {
nextKeys.add(optionKey);
nextKeys.add(normalizedOptionKey);
}
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 (
<Autocomplete
{...rootSelectionProps}
aria-label={ariaLabel || placeholder}
allowsEmptyCollection={allowsEmptyCollection}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
name={name}
selectionMode={selectionMode}
selectionMode={isMultiple ? 'single' : selectionMode}
variant={variant}
onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
......@@ -177,16 +194,14 @@ export default function XinyuanAutocomplete({
)}
>
{(option) => {
const isSelected = selectedKeyStringSet.has(toSelectionKey(option.id));
const isSelected = Boolean(option.isSelected);
return (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
isSelected={isSelected}
textValue={option.textValue || option.label}
{...(selectionMode === 'multiple'
? { onPress: () => handleMultipleOptionPress(option) }
: {})}
>
{renderOption ? (
renderOption(option)
......
......@@ -22,6 +22,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,
......@@ -996,19 +998,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}
......@@ -1173,6 +1191,16 @@ export default function RecordWorkbench({
<Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip>
) : null}
{onQuery ? (
<Button
isDisabled={loading}
size="sm"
type="submit"
variant="primary"
>
查询
</Button>
) : null}
</form>
) : null}
......
......@@ -103,6 +103,7 @@ export default function ReferenceSearchSelect({
<XinyuanAutocomplete
{...selectionProps}
aria-label="引用记录"
allowsEmptyCollection
emptyText="暂无可选记录"
inputValue={keyword}
options={autocompleteOptions}
......
......@@ -80,6 +80,7 @@ import {
buildProblemRectificationConfiguredListView,
buildProblemRectificationCreateFormFields,
buildProblemRectificationCreatePayload,
buildProblemRectificationFilterSchema,
getProblemRectificationFlowTableUrl,
getRecordCellText,
delayRequestItemsMatch,
......@@ -776,18 +777,8 @@ export default function ProblemRectificationListPage() {
const [recordsLoading, setRecordsLoading] = useState(false);
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [filters, setFilters] = useState({
keyword: '',
inspectionReportMonth: '',
progress: '',
auditStatus: '',
severityLevel: '',
overallRating: '',
primaryResponsibleDepartment: '',
problemFrom: '',
rootCause: '',
focusFlag: '',
});
const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({});
const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false);
......@@ -1054,8 +1045,12 @@ export default function ProblemRectificationListPage() {
}, [reloadList]);
const configuredView = useMemo(
() => buildProblemRectificationConfiguredListView(records, filters, activeGridView, subject),
[records, filters, activeGridView, subject],
() => buildProblemRectificationConfiguredListView(records, {}, activeGridView, subject),
[records, activeGridView, subject],
);
const dynamicFilterSchema = useMemo(
() => buildProblemRectificationFilterSchema(configuredView.columns || []),
[configuredView.columns],
);
const createFields = useMemo(
......@@ -1109,10 +1104,11 @@ export default function ProblemRectificationListPage() {
const handleCreate = async () => {
if (
!normalizeText(formValues.title) &&
!normalizeText(formValues.project_name) &&
!normalizeText(formValues.problem_brief)
) {
setCreateValidationMessage('检查项目名称、问题简述至少填写一项。');
setCreateValidationMessage('记录标题、检查项目名称、问题简述至少填写一项。');
return;
}
......@@ -1740,85 +1736,24 @@ export default function ProblemRectificationListPage() {
setActiveViewName(viewName);
setPage(0);
setSelectedIds([]);
setFilters({});
setPendingFilters({});
}}
filters={filters}
filterSchema={[
{
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,
},
]}
filters={pendingFilters}
filterSchema={dynamicFilterSchema}
onFiltersChange={(key, value) => {
setPendingFilters((current) => ({ ...current, [key]: value }));
}}
onClearFilters={() => {
setPendingFilters({});
setFilters({});
setPage(0);
setSelectedIds([]);
}}
onQuery={() => {
setFilters(pendingFilters);
setPage(0);
setSelectedIds([]);
setFilters((current) => ({ ...current, [key]: value }));
}}
title="问题清单"
columns={configuredView.columns || []}
......
......@@ -56,12 +56,19 @@
### 3.3 视图配置驱动约定
问题整改列表的新增表单、筛选和表格均读取当前激活 Grid View:
- 新增表单按 `settings.columns` 顺序渲染可见字段,并包含记录标题字段;标题是否必填跟随视图列配置,未配置短标题时使用标题作为默认回退值。
- 筛选控件由视图列的 Subject 字段类型自动生成。`select` 选项来自 `settings.options`,日期、数字、布尔、用户和引用字段使用对应控件;提交时按 `field:<path>` 通过 `/record/query` 远程筛选,不再提供独立关键词搜索。
- 引用和用户多选的输入摘要、列表勾选与草稿统一使用归一化 ID;引用写入只保存目标记录 `_id` 或 `_id[]`。
- 表格请求期间 DataGrid 使用居中的 loading 遮罩,空结果与加载中状态分开处理。
问题整改主列表与详情抽屉统一以 `getSubjectView(subjectName)` 返回的视图配置驱动,不再在页面内写死部门标签和字段顺序:
- `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab
- `settings.columns` 决定列表列顺序、宽度、固定列位置和排序优先级,列表页不得在视图列后追加自定义业务字段列
- `settings.groups` 决定列表列分组,例如把多个问题分类字段归入同一分组;前端需展示分组表头,同一分组下的列即使在 `settings.columns` 中不连续,也应在表格内聚拢展示,并支持每个分组独立折叠 / 展开,折叠后隐藏该分组子列
- `settings.shortTitle = true` 仅隐藏列表中的 `title` 展示列,不删除记录标题数据,也不改变标题字段参与的关键词查询、视图排序、详情标题或权限判断;标题列原本承载的部门明细数量 / 展开、延期申请阶段 / 详情和锁定状态迁移到固定的“问题动态”上下文列,详情、延期申请、锁定、复制和删除继续由固定行操作列提供
- `settings.shortTitle = true` 仅隐藏列表中的 `title` 展示列,不删除记录标题数据,也不改变标题字段的视图排序、详情标题或权限判断;标题列原本承载的部门明细数量 / 展开、延期申请阶段 / 详情和锁定状态迁移到固定的“问题动态”上下文列,详情、延期申请、锁定、复制和删除继续由固定行操作列提供
- `settings.quickSelect` 决定列表是否展示勾选列,用于批量推送、删除等操作
- `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围
- Grid 视图的 `settings.viewAuth` 决定当前用户是否能看到该视图;普通用户未配置 `viewAuth` 时默认不可见,仅系统管理员和问题整改应用 `problem_rectification_new` 自身的“管理”角色可见。全部 Grid 视图均无权限时不得生成绕过授权的兜底视图、查询记录或展示新增/批量/行操作入口
......@@ -78,7 +85,7 @@
- `problem_rectification` 与 `issue_rectification_archive` 列表页统一复用 `RecordWorkbench` 作为“结构化表格 + 工具栏 + 详情抽屉入口”的工作台容器
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、筛选条件、关键词和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size`
- `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、视图字段筛选条件和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size`
- 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底
- 主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。
- 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数
......
......@@ -259,12 +259,16 @@ export function isDateFieldType(fieldType) {
}
export function normalizeRecordId(value) {
if (!value) {
if (value === null || value === undefined || value === '') {
return '';
}
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);
......@@ -498,7 +502,7 @@ export function sortRecordsByView(records, view) {
}
export function getSelectOptions(fieldDef) {
return toArray(fieldDef?.settings?.options || fieldDef?.options).map((option) => {
return toArray(fieldDef?.settings?.options).map((option) => {
if (typeof option === 'string') {
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