Commit fbbe3f5a authored by drigle's avatar drigle

feat: support department item editing and risk workflow

parent 6f750b02
......@@ -21,12 +21,13 @@
- **常量**:使用全大写下划线 (SNAKE_CASE)。
- **技术栈偏好**:
- 优先使用 React Hooks 和函数组件。
- 样式处理:**优先使用 Tailwind Plus / Catalyst UI Kit(见 `src/catalyst-ui-kit`)+ Tailwind CSS**。
- 交互组件:**优先使用 Headless UI**(已在 Catalyst 组件内封装,除非缺组件才直接用 `@headlessui/react`)。
- 样式处理:**统一使用 HeroUI 与 HeroUI Pro 的主题、Token 和组件样式,配合 Tailwind CSS 做布局**。
- 交互组件:**优先使用 HeroUI / HeroUI Pro 的 compound API**,由组件自身提供焦点管理、键盘导航、Portal 和可访问性行为。
- 组件选择优先级(从高到低):
1. `src/catalyst-ui-kit/javascript/*`(Button/Input/Dialog/Table/Combobox/SidebarLayout/...)
2. 基于 Catalyst 样式规范的自定义组件(仅在 Kit 无现成组件时)
3. 原生 HTML + 自写样式(尽量避免)
1. `@heroui/react`(Button/Input/Modal/Autocomplete/Table 等 OSS 组件)
2. `@heroui-pro/react/*`(DataGrid/Sidebar/Navbar/DropZone/KPI/EmptyState 等 Pro 组件)
3. 基于 HeroUI 语义 Token 的自定义组合组件(仅在 HeroUI/HeroUI Pro 无现成组件时)
4. 原生 HTML + 自写样式(尽量避免)
## 4. 接口请求规范
......@@ -39,12 +40,12 @@
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(Tailwind Plus / Headless UI 强制约束)
## 6. UI 规范(HeroUI / HeroUI Pro 强制约束)
- **全项目 UI 统一**:默认使用 `src/catalyst-ui-kit` 中的 Catalyst 组件与样式体系,避免引入其它 UI 框架。
- **Headless UI 优先**:弹窗/下拉/选择器等交互优先用 Headless UI(直接或通过 Catalyst 组件)。
- **一致性**:表单控件优先用 Catalyst 的 `Input/Select/Checkbox/Textarea/Fieldset`,表格用 `Table`,对话框用 `Dialog`。
- **可访问性**:移动端侧栏/弹层必须使用 Headless UI 的 Dialog 等可访问性组件(focus trap / esc 关闭等)。
- **全项目 UI 统一**:默认使用 `@heroui/react` 与 `@heroui-pro/react` 的组件和样式体系,禁止新增旧版 UI 框架或其它平行 UI 框架依赖。
- **HeroUI compound API 优先**:弹窗、下拉、选择器、标签组等交互直接使用 HeroUI/HeroUI Pro 官方 compound API,不访问内部 slot/BEM 实现页面私有外观。
- **一致性**:表单控件优先使用 HeroUI `Input/TextArea/Checkbox/DatePicker/Autocomplete`,表格优先使用 HeroUI Pro `DataGrid`,对话框使用 HeroUI `Modal/AlertDialog`。
- **可访问性**:移动端侧栏/弹层必须使用 HeroUI Pro `Sheet/Sidebar`、HeroUI `Modal` 等组件提供的 focus trap、Esc 关闭、Portal 和键盘导航能力。
# AI 开发执行规范
......@@ -54,12 +55,12 @@
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- |
| **文本 (String)** | Catalyst `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(Headless UI Combobox) | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | Catalyst `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | Catalyst `Input`(`type="date"` / `datetime-local`) | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | Catalyst `Checkbox` | 映射为 true/false |
| **JSON** | Catalyst `Textarea` | 需要包含 JSON 校验逻辑 |
| **文本 (String)** | HeroUI `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(HeroUI `Autocomplete` compound API) | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | HeroUI `Input`(`type="number"`) | 仅允许输入数字 |
| **日期 (Date)** | HeroUI `DatePicker` / `DateField` | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | HeroUI `Checkbox` | 映射为 true/false |
| **JSON** | HeroUI `TextArea` | 需要包含 JSON 校验逻辑 |
| **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 |
---
......
......@@ -4,7 +4,7 @@
1. 业务控制层:现有 services、hooks、路由、权限、查询参数、分页口径和保存回调保持不变。
2. 页面编排层:页面只组合业务数据与 HeroUI 原生组件,`className` 仅用于布局。
3. 共享组合层:`AppShell`、`DataTable`、`XinyuanAutocomplete`、`XinyuanModal` 等均是 HeroUI-native 的业务无关组合,不兼容旧 Catalyst props。
3. 共享组合层:`AppShell`、`DataTable`、`XinyuanAutocomplete`、`XinyuanModal` 等均是 HeroUI-native 的业务无关组合,不兼容已废弃的旧 UI 适配层 props。
4. 主题层:HeroUI styles -> HeroUI Pro CSS -> 权威 `theme.css` -> 批准的 `tokens.css` -> Shell 布局样式。
## 依赖边界
......
......@@ -20,7 +20,7 @@
- [x] 弹窗查看态为单一 L2 字段组且字段无框,编辑态移除字段组 Surface;只读字段不使用禁用输入框模拟。
- [x] 所有动态表单使用 HeroUI compound 控件。
- [x] 所有上传使用 HeroUI Pro DropZone。
- [x] 活动 Catalyst/Headless UI 引用为零。
- [x] 活动业务源码统一使用 HeroUI/HeroUI Pro,未引入旧版 UI 框架或其它平行 UI 框架。
- [x] 活动原生 select/date/file 控件为零(DropZone 内部 input 为不可见技术输入)。
- [x] 页面私有视觉 CSS 扫描为零;页面 className 仅承担布局、间距和响应式。
- [x] 全局复扫移除业务 JSX 的 Surface/KPI/DataGrid 圆角覆盖与旧 Disclosure BEM class。
......
......@@ -13,7 +13,7 @@
- 页面 `className` 只处理布局、宽度、间距、对齐和响应式。
- 组件外观由 HeroUI variant/size/compound API 与权威主题负责。
- 不引入 Catalyst、Headless UI、兼容旧 props 的 HeroUI 包装层或可见原生选择/日期/文件控件。
- 不引入旧版 UI 框架、兼容旧 props 的 HeroUI 包装层或可见原生选择/日期/文件控件。
- 列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。
- 筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。
- 受控 Autocomplete 的已选值必须可逆操作:单选使用官方 `Autocomplete.ClearButton` 清除当前值;多选在 Trigger 内使用官方 `TagGroup + Tag + Tag.RemoveButton` 将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方 `ListBox.Item` 再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控 `selectedKeys` 派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。已选 TagGroup 使用 HeroUI `default` variant,Tag 使用官方 `accent-soft-hover` / `accent-soft-foreground` 语义 Token,继承鑫元品牌辅助色并与字段背景拉开层级,避免纯白 `surface`。
......
......@@ -30,11 +30,13 @@
| `/integrity_risk_control_archive` | 历史归档 | 只读行为不变 | 已验收 |
| `/control_measure_form` | 部门填报与审核 | 保留流程状态、驳回与审核回调 | 已验收 |
## 旧 UI 清单
## 迁移前旧 UI 清单(历史基线)
- Shell:Catalyst Sidebar/Navbar/Avatar/Button。
- 页面:Catalyst Button、Dialog、Table、Checkbox、Input、Select、Textarea。
- Portal:Headless UI Dialog、Combobox、Listbox。
以下内容仅记录 HeroUI/HeroUI Pro 迁移前的实现状态,用于审计对照,不代表当前技术栈或新增代码规范。
- Shell:旧版 Sidebar/Navbar/Avatar/Button 组件。
- 页面:旧版 Button、Dialog、Table、Checkbox、Input、Select、Textarea 组件。
- Portal:旧版 Dialog、Combobox、Listbox 组件。
- 列表:自制 Table、选择列、分页、空状态和工具栏。
- 图表:未发现活动业务图表、ECharts 私有主题或自制 Tooltip;源码没有 `PieChart`/`BarChart`/`ComposedChart`/`ChartTooltip` 活动页面。`recharts` 仍是原始依赖并保留在锁文件中,但没有活动 import,不制造测试数据。
- 饼图 / 环形图专项审计:当前仍无真实 Pie/Donut 调用点,未新增伪造图表数据或页面。已新增 `--xinyuan-pie-chart-*` / `--rc-pie-chart-*` 专用 Token、稳定分类 key 冲突消解映射和 `applyXinyuanPieChartPalette` 共享适配器;未来接入时不得由页面直接传色或改动普通图表色板。
......@@ -180,4 +182,4 @@
- `shortTitle`:现有 `buildViewColumns` 未按严格布尔值隐藏 `title`,详情 Header 和字段区也未跟随当前激活 Detail View 隐藏记录标题;关键词搜索目前仅使用可见列,隐藏标题后会丢失标题检索语义。
- 通知:目标 DataService 已保留平台 `queryUserNotice`、`readUserNotice`、`unReadNoticeCount` 和动态 action 接口,但目标应用尚无 `/notifications` 路由、通知页面/service 或 Navbar Badge;Sidebar 当前没有通知入口,符合要求。
- 文件字段:运行时 Subject 协议支持 `file` 单值对象和 `multiple=true` 数组;现有详情仅直接上传业务附件。目标详情曾残留问题整改材料服务引用,必须改为廉洁风险防控自己的 `source_app`,并保持 `{name,type,size,url}` 快照协议、延迟建档和重试去重语义。
- UI 边界:活动业务源码使用直接 HeroUI/HeroUI Pro 组合;DataGrid、Modal、DropZone、Autocomplete、Badge/Tooltip 的新增实现继续保持原生 compound API,不引入 Catalyst、Headless UI、旧适配器或页面私有皮肤。
- UI 边界:活动业务源码使用直接 HeroUI/HeroUI Pro 组合;DataGrid、Modal、DropZone、Autocomplete、Badge/Tooltip 的新增实现继续保持原生 compound API,不引入旧版 UI 框架、旧适配器或页面私有皮肤。
......@@ -22,6 +22,8 @@ import {
isReferenceFieldType,
getViewRestrictionDefaults,
normalizeValueForSubmit,
isFormulaFieldType,
resolveFieldValue,
shouldHideViewTitleColumn,
toArray,
} from '@/utils/subjectView';
......@@ -223,7 +225,9 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
<DynamicField
key={nestedField.name}
fieldDef={nestedField}
value={item?.[nestedField.name]}
value={isFormulaFieldType(nestedField.type)
? resolveFieldValue(item, nestedField.name, nestedField)
: item?.[nestedField.name]}
disabled={disabled}
onChange={(nextValue) => handleFieldChange(nestedField.name, nextValue)}
depth={depth + 1}
......@@ -294,7 +298,16 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
const fieldPlaceholder = getFieldPlaceholder(fieldDef);
let control = null;
if (type === 'boolean') {
if (isFormulaFieldType(type)) {
control = (
<Input
isReadOnly
value={value ?? ''}
placeholder="保存后由公式自动计算"
variant="secondary"
/>
);
} else if (type === 'boolean') {
control = (
<Checkbox
isSelected={Boolean(value)}
......@@ -422,6 +435,10 @@ export async function prepareValuesForSubmit(subject, values, path = '') {
const fieldLabel = `${path}${getFieldDisplayLabel(field)}`;
const fieldType = String(field.type || 'text').toLowerCase();
if (isFormulaFieldType(fieldType)) {
continue;
}
if (isRequiredField(field) && isEmptyValue(value)) {
errors.push(`${fieldLabel}为必填项`);
}
......@@ -598,15 +615,19 @@ export default function DynamicRecordDialog({
/>
</TextField> : null}
{dialogFields.map((field) => {
const fieldType = String(field.type || '').toLowerCase();
const fieldValue = isFormulaFieldType(fieldType)
? resolveFieldValue(values, field.name, field)
: values[field.name];
const isFullWidth = ['long_text', 'rich_text', 'object', 'form', 'subject', 'file'].includes(
String(field.type || '').toLowerCase(),
fieldType,
);
return (
<div className={isFullWidth ? 'md:col-span-2' : undefined} key={field.name}>
<DynamicField
fieldDef={field}
value={values[field.name]}
disabled={saving || (editing && isPrimaryField(field)) || (
value={fieldValue}
disabled={saving || isFormulaFieldType(fieldType) || (editing && isPrimaryField(field)) || (
!editing && Object.prototype.hasOwnProperty.call(restrictionDefaults.metadata, field.name)
)}
onChange={(nextValue) => setValues((current) => ({
......
......@@ -161,6 +161,11 @@ function normalizeActionContext(context, helpers) {
...context,
selectedRecords: (context.selectedRecords || []).map((record) => record.raw || record),
record: context.record ? context.record.raw || context.record : undefined,
parentRecord: context.parentRecord
? context.parentRecord.raw || context.parentRecord
: undefined,
isExpandedRecord: Boolean(context.isExpandedRecord),
isExpandedPanel: Boolean(context.isExpandedPanel),
...helpers,
};
}
......@@ -179,6 +184,7 @@ export default function SubjectRecordWorkbench({
toolbarActions = [],
bulkActions = [],
rowActions = [],
expandedRowActions = [],
compactBulkActions = false,
hiddenColumnFields = [],
detailDrawerProps = {},
......@@ -474,6 +480,9 @@ export default function SubjectRecordWorkbench({
}]
: []),
] : [];
const builtExpandedRowActions = hasAccessibleGridView
? expandedRowActions.map(wrapAction)
: [];
if (loading && !hasAccessibleGridView) {
return (
......@@ -534,6 +543,7 @@ export default function SubjectRecordWorkbench({
toolbarActions={builtToolbarActions}
bulkActions={builtBulkActions}
rowActions={builtRowActions}
expandedRowActions={builtExpandedRowActions}
isRowExpandable={isRowExpandable}
getExpandedRecordRows={getExpandedRecordRows ? resolvedExpandedRecordRows : undefined}
detailPanel={{
......
......@@ -27,7 +27,12 @@ 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 {
formatFieldValue,
getFieldValue,
normalizeText,
resolveFieldValue,
} from '@/utils/subjectView';
import {
Alert,
Avatar,
......@@ -323,7 +328,7 @@ function getFilterPlaceholder(field) {
function defaultRenderCell(record, column) {
const sourceRecord = record?.raw || record;
const value = getFieldValue(sourceRecord, column.field);
const value = resolveFieldValue(sourceRecord, column.field, column.fieldDef);
const text = formatFieldValue(value, column.fieldDef) || '-';
if (column.fieldDef?.type === 'user') {
......@@ -542,6 +547,7 @@ export default function RecordWorkbench({
toolbarActions = [],
bulkActions = [],
rowActions = [],
expandedRowActions = [],
detailPanel = null,
states = {},
renderCell,
......@@ -605,21 +611,25 @@ export default function RecordWorkbench({
[records, getRecordId],
);
const resolvedActionColumnWidth = useMemo(
() => resolveActionColumnWidth(rowActions, actionColumnWidth),
[rowActions, actionColumnWidth],
() => resolveActionColumnWidth(
rowActions.length >= expandedRowActions.length ? rowActions : expandedRowActions,
actionColumnWidth,
),
[rowActions, expandedRowActions, actionColumnWidth],
);
const hasRowActions = rowActions.length > 0 || expandedRowActions.length > 0;
const stickyColumns = useMemo(
() =>
buildStickyColumnMeta(
orderedVisibleColumns,
selectable,
rowActions.length > 0,
hasRowActions,
resolvedActionColumnWidth,
),
[
orderedVisibleColumns,
selectable,
rowActions.length,
hasRowActions,
resolvedActionColumnWidth,
],
);
......@@ -801,6 +811,12 @@ export default function RecordWorkbench({
});
if (Array.isArray(expandedRecords)) {
expandedRecords.forEach((expandedRecord, index) => {
const expandedRowContext = {
...rowContext,
record: expandedRecord,
parentRecord: record,
isExpandedRecord: true,
};
dataGridRows.push({
key:
expandedRecord?.key ||
......@@ -811,7 +827,7 @@ export default function RecordWorkbench({
parentRecord: record,
expandable: false,
expanded: false,
rowContext,
rowContext: expandedRowContext,
isExpandedRecord: true,
});
});
......@@ -821,7 +837,11 @@ export default function RecordWorkbench({
key: `${recordId}-expanded`,
record,
parentRecord: record,
rowContext,
rowContext: {
...rowContext,
parentRecord: record,
isExpandedPanel: true,
},
isExpandedPanel: true,
expandedContent: renderExpandedRow({ record, rowContext }),
});
......@@ -914,13 +934,21 @@ export default function RecordWorkbench({
}
if (columnIndex === 0 && detailPanel) {
const value = getFieldValue(row.record?.raw || row.record, column.field);
const value = resolveFieldValue(
row.record?.raw || row.record,
column.field,
column.fieldDef,
);
const text = formatFieldValue(value, column.fieldDef) || '-';
return (
<DataTablePrimaryAction
className="h-auto w-full min-w-0 justify-start px-0 text-left"
text={text}
onPress={() => row.rowContext.openDetail(row.record)}
onPress={() => row.rowContext.openDetail(
row.isExpandedRecord || row.isExpandedPanel
? row.parentRecord
: row.record,
)}
/>
);
}
......@@ -929,7 +957,7 @@ export default function RecordWorkbench({
},
};
}),
...(rowActions.length > 0
...(hasRowActions
? [
{
key: 'actions',
......@@ -939,10 +967,10 @@ export default function RecordWorkbench({
pin: 'end',
isActions: true,
render: (row) => {
if (row.isExpandedRecord || row.isExpandedPanel) {
return null;
}
const visibleRowActions = rowActions.filter(
const actions = row.isExpandedRecord || row.isExpandedPanel
? expandedRowActions
: rowActions;
const visibleRowActions = actions.filter(
(action) => !isActionHidden(action, row.rowContext),
);
return (
......@@ -961,7 +989,13 @@ export default function RecordWorkbench({
]
: []),
];
const disabledDataGridRowKeys = dataGridRows
// Expanded rows can expose their own actions (for example, editing a nested
// department item). Do not disable the whole row in that case: HeroUI
// DataGrid's disabledKeys disables descendant buttons as well as selection.
// Selection is still filtered to parent record keys by the workbench.
const disabledDataGridRowKeys = expandedRowActions.length > 0
? []
: dataGridRows
.filter((row) => row.isExpandedRecord || row.isExpandedPanel)
.map((row) => String(row.key));
const toolbarSearchField = filterSchema.find((field) => field.icon === 'search');
......@@ -1229,7 +1263,7 @@ export default function RecordWorkbench({
selectable
? (keys) =>
onSelectionChange?.(
keys.filter((key) => currentPageRecordIds.includes(key)),
keys.filter((key) => currentPageRecordIds.includes(String(key))),
)
: undefined
}
......
......@@ -10,9 +10,9 @@ import { loadSubject, queryRecords } from '@/services/DataService';
import {
formatDateText,
formatFieldValue,
getFieldValue,
getReferenceSubjectName,
normalizeRecordId,
resolveFieldValue,
toArray,
} from '@/utils/subjectView';
......@@ -165,6 +165,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
_id: { $in: referenceIds },
},
mode: 'full',
calculated: true,
})
: Promise.resolve({ records: populatedRecords }),
]);
......@@ -250,7 +251,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
isPrimary: index === 0,
render: (record) => (
<CellValue
value={getFieldValue(record, column.field)}
value={resolveFieldValue(record, column.field, column.fieldDef)}
field={column}
/>
),
......
......@@ -5,7 +5,7 @@ import {
buildViewColumns,
getFieldDisplayLabel,
getFieldPlaceholder,
getFieldValue,
resolveFieldValue,
isFieldDisabledByView,
} from '@/utils/subjectView';
......@@ -45,7 +45,7 @@ export default function SubjectViewContent({
className="grid min-w-0 grid-cols-1 gap-y-2"
>
{contentFields.map((field) => {
const value = getFieldValue(editableRecord, field.field);
const value = resolveFieldValue(editableRecord, field.field, field.fieldDef);
const fieldLabel = getFieldDisplayLabel(field.fieldDef, field.field);
const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view)
......
......@@ -23,6 +23,7 @@ import {
getFieldDisplayLabel,
getFieldPlaceholder,
getSelectOptions,
isFormulaFieldType,
isDateFieldType,
isReferenceFieldType,
getReferenceSubjectName,
......@@ -252,6 +253,17 @@ function ControlledFieldEditor({
}
};
if (isFormulaFieldType(fieldType)) {
return (
<Input
isReadOnly
value={value ?? ''}
placeholder="保存后由公式自动计算"
variant={fieldVariant}
/>
);
}
if (fieldType === 'boolean') {
return (
<Checkbox isSelected={Boolean(value)} onChange={(nextValue) => onChange?.(nextValue)}>
......@@ -433,12 +445,13 @@ export default function TailwindEditableField({
const canEdit = !readonly && !disabled;
const fieldType = fieldDef?.type || 'text';
const isFormula = isFormulaFieldType(fieldType);
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
if (mode === 'view' || (mode === 'edit' && (readonly || disabled))) {
if (mode === 'view' || isFormula || (mode === 'edit' && (readonly || disabled))) {
return <ReadonlyFieldValue value={value} fieldDef={fieldDef} />;
}
......
......@@ -96,7 +96,7 @@ export function useSubjectRecord(
setError('');
try {
const record = await loadRecord(recordName);
const record = await loadRecord(recordName, true);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewSummaries] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
......@@ -151,7 +151,10 @@ export function useSubjectRecord(
return;
}
const normalizedChanges = changes.filter((change) => change?.fieldPath);
const normalizedChanges = changes.filter((change) => (
change?.fieldPath
&& String(change.fieldDef?.type || '').toLowerCase() !== 'formula'
));
if (normalizedChanges.length === 0) {
return;
}
......
......@@ -23,7 +23,7 @@ import {
} from '@/utils/subjectView';
const EMPTY_ARRAY = [];
const NON_EDITABLE_FIELD_TYPES = new Set(['form', 'subject']);
const NON_EDITABLE_FIELD_TYPES = new Set(['form', 'subject', 'formula']);
function cloneRecord(record) {
return record ? JSON.parse(JSON.stringify(record)) : null;
......
......@@ -16,6 +16,7 @@ import {
normalizeIds,
rejectControlMeasureFlow,
submitControlMeasureFlow,
withdrawControlMeasureFlow,
} from './IntegrityRiskService';
function normalizeRecord(record) {
......@@ -58,6 +59,34 @@ function RejectConfirmDialog({ type, loading, onClose, onConfirm }) {
);
}
function WithdrawConfirmDialog({ open, loading, onClose, onConfirm }) {
return (
<XinyuanModal
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen && !loading) onClose?.();
}}
size="sm"
title="确认撤回审批"
description="撤回后审批人的 OA 待办会完成,表单恢复为填报人可填写状态。"
footer={(
<>
<Button isDisabled={loading} variant="secondary" onPress={onClose}>取消</Button>
<Button isDisabled={loading} isPending={loading} onPress={onConfirm}>
{loading ? '撤回中...' : '确认撤回'}
</Button>
</>
)}
>
<Alert status="warning">
<Alert.Content>
<Alert.Description>已填写的数据会保留,审批人尚未处理时才可以撤回。</Alert.Description>
</Alert.Content>
</Alert>
</XinyuanModal>
);
}
export default function ControlMeasureFormPage() {
const location = useLocation();
const userInfo = useUserInfo();
......@@ -67,6 +96,7 @@ export default function ControlMeasureFormPage() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [rejectType, setRejectType] = useState('');
const [withdrawOpen, setWithdrawOpen] = useState(false);
const messageId = useMemo(
() => new URLSearchParams(location.search).get('id') || '',
......@@ -158,6 +188,21 @@ export default function ControlMeasureFormPage() {
});
}
if (
isEditor
&& editorStatus === FLOW_STATUS.complete
&& auditorStatus === FLOW_STATUS.pending
) {
toolbarActions.push({
key: 'withdraw',
label: actionLoading === 'withdraw' ? '撤回中...' : '撤回审批',
variant: 'secondary',
tone: 'warning',
disabled: Boolean(actionLoading) || records.length === 0,
onClick: () => setWithdrawOpen(true),
});
}
if (
isAuditor
&& editorStatus === FLOW_STATUS.complete
......@@ -228,6 +273,15 @@ export default function ControlMeasureFormPage() {
if (succeeded) setRejectType('');
};
const confirmWithdraw = async () => {
const succeeded = await runFlowAction(
'withdraw',
() => withdrawControlMeasureFlow(messageRecord, userInfo, returnUrl),
'已撤回审批,表单已恢复为可填写状态。',
);
if (succeeded) setWithdrawOpen(false);
};
if (workspace?.isDepartmentFlow) {
return (
<DepartmentControlMeasureFlow
......@@ -290,6 +344,12 @@ export default function ControlMeasureFormPage() {
onClose={() => setRejectType('')}
onConfirm={confirmReject}
/>
<WithdrawConfirmDialog
open={withdrawOpen}
loading={actionLoading === 'withdraw'}
onClose={() => setWithdrawOpen(false)}
onConfirm={confirmWithdraw}
/>
</main>
);
}
......@@ -2,7 +2,11 @@ import { Alert, Button, Surface } from '@heroui/react';
import XinyuanModal from '@/components/XinyuanModal';
import { DynamicField, prepareValuesForSubmit } from '@/components/DynamicRecordDialog';
import TailwindEditableField from '@/pages/RecordDetailDrawer/components/TailwindEditableField';
import { getFieldDisplayLabel } from '@/utils/subjectView';
import {
getFieldDisplayLabel,
isFormulaFieldType,
resolveFieldValue,
} from '@/utils/subjectView';
import { useEffect, useMemo, useState } from 'react';
const HIDDEN_FIELD_NAMES = new Set([
......@@ -50,8 +54,7 @@ export default function DepartmentControlItemDialog({
const fields = useMemo(
() => (subject?.fields || []).filter((field) =>
!HIDDEN_FIELD_NAMES.has(field.name)
&& String(field.type || '').toLowerCase() !== 'formula'),
!HIDDEN_FIELD_NAMES.has(field.name)),
[subject],
);
......@@ -74,7 +77,11 @@ export default function DepartmentControlItemDialog({
<div className="mt-1.5 text-foreground">{item?.business_unit || '暂无内容'}</div>
</div>
{fields.map((field) => {
const isFullWidth = ['long_text', 'rich_text', 'subject', 'object', 'form'].includes(field.type);
const fieldType = String(field.type || '').toLowerCase();
const fieldValue = isFormulaFieldType(fieldType)
? resolveFieldValue(values, field.name, field)
: values[field.name];
const isFullWidth = ['long_text', 'rich_text', 'subject', 'object', 'form'].includes(fieldType);
return (
<div
......@@ -93,14 +100,14 @@ export default function DepartmentControlItemDialog({
fieldDef={field}
mode="view"
readonly
value={values[field.name]}
value={fieldValue}
/>
</div>
</>
) : (
<DynamicField
fieldDef={field}
value={values[field.name]}
value={fieldValue}
disabled={loading}
onChange={(nextValue) => setValues((current) => ({
...current,
......
......@@ -3,7 +3,7 @@ import XinyuanModal from '@/components/XinyuanModal';
import RecordWorkbench from '@/components/record-workbench';
import { queryUsers } from '@/services/DataService';
import { normalizeUserLabel } from '@/utils/subjectView';
import { CheckCircleIcon, Close, Edit, Eye, Send } from '@/components/AppIcons';
import { ArrowPathIcon, CheckCircleIcon, Close, Edit, Eye, Send } from '@/components/AppIcons';
import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import DepartmentControlItemDialog from './DepartmentControlItemDialog';
......@@ -17,6 +17,7 @@ import {
rejectDepartmentControlItem,
saveDepartmentControlItem,
submitDepartmentControlItem,
withdrawDepartmentControlItem,
} from './IntegrityRiskService';
const COLUMNS = [
......@@ -42,6 +43,7 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [rejectContext, setRejectContext] = useState(null);
const [withdrawContext, setWithdrawContext] = useState(null);
const [userLabels, setUserLabels] = useState({});
const rows = useMemo(
......@@ -130,6 +132,14 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
`${record.business_unit}部门明细已送审。`,
),
},
{
key: 'withdraw-department-item',
label: '撤回审批',
icon: ArrowPathIcon,
hidden: ({ record }) => !getDepartmentItemPermissions(record, userInfo).canWithdraw,
disabled: ({ record }) => Boolean(actionKey) || !record.auditor,
onClick: ({ record }) => setWithdrawContext(record),
},
{
key: 'approve-department-item',
label: '审批通过',
......@@ -204,6 +214,17 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
if (succeeded) setRejectContext(null);
};
const confirmWithdraw = async () => {
const row = withdrawContext;
if (!row) return;
const succeeded = await runAction(
`withdraw-${row.id}`,
() => withdrawDepartmentControlItem(actionArgs(row)),
`${row.business_unit}部门明细已撤回审批,可继续填写。`,
);
if (succeeded) setWithdrawContext(null);
};
const activePermissions = activeItem
? getDepartmentItemPermissions(activeItem, userInfo)
: { canEdit: false };
......@@ -260,6 +281,29 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
</>
)}
/>
<XinyuanModal
isOpen={Boolean(withdrawContext)}
onOpenChange={(isOpen) => {
if (!isOpen && !actionKey) setWithdrawContext(null);
}}
size="sm"
title="确认撤回审批"
description={`撤回“${withdrawContext?.business_unit || '-'}”部门明细的审批申请。`}
footer={(
<>
<Button isDisabled={Boolean(actionKey)} variant="secondary" onPress={() => setWithdrawContext(null)}>取消</Button>
<Button isDisabled={Boolean(actionKey)} isPending={Boolean(actionKey)} onPress={confirmWithdraw}>
{actionKey ? '撤回中...' : '确认撤回'}
</Button>
</>
)}
>
<div className="space-y-2 text-sm text-muted">
<p>撤回后将完成审批人的 OA 待办,已填写内容会保留。</p>
<p>当前部门明细会恢复为填报人可编辑状态,其他部门不受影响。</p>
</div>
</XinyuanModal>
</main>
);
}
......@@ -55,7 +55,7 @@ export default function DepartmentPushDialog({ open, targets, loading, onClose,
primary={assignment.business_unit}
secondary={showParentTitle ? assignment.parent_title : '将生成 1 条部门明细'}
/>
{assignment.reuse_existing ? <Chip className="mt-1" color="warning" size="sm">沿用未完成子项</Chip> : null}
{assignment.reuse_existing ? <Chip className="mt-1" color="warning" size="sm">沿用部门子项原始数据</Chip> : null}
</div>
),
},
......
......@@ -2,6 +2,7 @@ import { Button, Surface } from '@heroui/react';
import FileUploadField from '@/components/FileUploadField';
import XinyuanModal from '@/components/XinyuanModal';
import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import DepartmentControlItemDialog from './DepartmentControlItemDialog';
import { useUserInfo } from '@/hooks/user';
import { useMemo, useState } from 'react';
import {
......@@ -13,11 +14,15 @@ import {
exportProcessTable,
exportShowTable,
INTEGRITY_RISK_SUBJECTS,
getDepartmentItemPermissions,
loadDepartmentControlItemSubject,
saveDepartmentControlItem,
pushControlMeasureForm,
replaceMeasureDescriptionsFromWorkbook,
replaceShowTableFromWorkbook,
normalizeDepartmentControlItems,
} from './IntegrityRiskService';
import { Edit, Eye } from '@/components/AppIcons';
import DepartmentPushDialog from './DepartmentPushDialog';
const PAGE_CONFIG = {
......@@ -90,6 +95,10 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
const [importContext, setImportContext] = useState(null);
const [importFile, setImportFile] = useState(null);
const [importType, setImportType] = useState('');
const [departmentSubject, setDepartmentSubject] = useState(null);
const [departmentEditor, setDepartmentEditor] = useState(null);
const [departmentEditorLoading, setDepartmentEditorLoading] = useState(false);
const [departmentEditorActionKey, setDepartmentEditorActionKey] = useState('');
const runAction = async ({ key, context, action, successMessage, reload = false }) => {
setBusyKey(key);
......@@ -154,6 +163,17 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
action: exportProcessTable,
successMessage: (count) => `已导出 ${count} 条过程表记录。`,
}),
}, {
key: 'export-process-measures',
label: busyKey === 'export-process-measures' ? '导出中...' : '导出防控措施',
variant: 'secondary',
disabled: disabledWhileBusy,
onClick: (context) => runAction({
key: 'export-process-measures',
context,
action: () => exportPreventionMeasures(INTEGRITY_RISK_SUBJECTS.process),
successMessage: (count) => `已导出 ${count} 条去重后的防控措施。`,
}),
}],
bulkActions: [
{
......@@ -303,6 +323,105 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
if (succeeded) setPushContext(null);
};
const resolveDepartmentItemContext = (context) => {
const parentRecord = context?.parentRecord?.raw
|| context?.parentRecord
|| context?.record?.parentRecord?.raw
|| context?.record?.parentRecord
|| context?.record?.raw
|| context?.record;
const parentId = parentRecord?._id || parentRecord?.id || parentRecord?.name;
const expandedRecord = context?.record?.raw || context?.record;
const itemId = expandedRecord?.metadata?.item_id || expandedRecord?.item_id;
const item = normalizeDepartmentControlItems(parentRecord)
.find((candidate) => candidate.item_id === itemId);
return { parentRecord, parentId, item };
};
const openDepartmentItem = async (context, readonly = false) => {
const { parentId, item } = resolveDepartmentItemContext(context);
if (!parentId || !item) {
context?.setError?.('找不到对应的部门子项,请刷新主界面后重试。');
return;
}
const permissions = getDepartmentItemPermissions(item, userInfo);
if (!readonly && !permissions.canEdit) {
context?.setError?.('当前用户没有编辑该部门子项的权限。');
return;
}
setDepartmentEditorLoading(true);
context?.setError?.('');
try {
const subject = departmentSubject || await loadDepartmentControlItemSubject();
setDepartmentSubject(subject);
setDepartmentEditor({
item,
parentId,
readonly: readonly || !permissions.canEdit,
setError: context?.setError,
setSuccess: context?.setSuccess,
reload: context?.reload,
});
} catch (error) {
context?.setError?.(error?.message || '部门子项结构加载失败,请稍后重试。');
} finally {
setDepartmentEditorLoading(false);
}
};
const expandedRowActions = pageType === 'process' ? [
{
key: 'edit-department-item',
label: '编辑子项',
icon: Edit,
hidden: (context) => {
const { item } = resolveDepartmentItemContext(context);
return !item || !getDepartmentItemPermissions(item, userInfo).canEdit;
},
disabled: () => departmentEditorLoading || Boolean(departmentEditorActionKey),
onClick: (context) => openDepartmentItem(context, false),
},
{
key: 'view-department-item',
label: '查看子项',
icon: Eye,
hidden: (context) => {
const { item } = resolveDepartmentItemContext(context);
return !item || getDepartmentItemPermissions(item, userInfo).canEdit;
},
disabled: () => departmentEditorLoading || Boolean(departmentEditorActionKey),
onClick: (context) => openDepartmentItem(context, true),
},
] : [];
const closeDepartmentEditor = () => {
if (departmentEditorActionKey) return;
setDepartmentEditor(null);
};
const handleDepartmentItemSave = async (values) => {
if (!departmentEditor) return;
const actionKey = `save-department-${departmentEditor.item.item_id}`;
setDepartmentEditorActionKey(actionKey);
departmentEditor.setError?.('');
departmentEditor.setSuccess?.('');
try {
await saveDepartmentControlItem({
parentId: departmentEditor.parentId,
itemId: departmentEditor.item.item_id,
values,
user: userInfo,
});
departmentEditor.setSuccess?.('部门子项已保存,主表数据已自动合并。');
await departmentEditor.reload?.({ keepFeedback: true });
setDepartmentEditor(null);
} catch (error) {
departmentEditor.setError?.(error?.message || '部门子项保存失败,请稍后重试。');
} finally {
setDepartmentEditorActionKey('');
}
};
return (
<>
<SubjectRecordWorkbench
......@@ -319,6 +438,7 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
hiddenColumnFields={pageConfig.hiddenColumnFields || []}
toolbarActions={toolbarActions}
bulkActions={bulkActions}
expandedRowActions={expandedRowActions}
detailDrawerProps={{
title: `${pageConfig.title}详情`,
readonly: pageConfig.readonly,
......@@ -357,6 +477,15 @@ export default function IntegrityRiskWorkbenchPage({ pageType }) {
onClose={() => setPushContext(null)}
onSubmit={confirmPush}
/>
<DepartmentControlItemDialog
open={Boolean(departmentEditor)}
item={departmentEditor?.item}
subject={departmentSubject}
readonly={Boolean(departmentEditor?.readonly)}
loading={Boolean(departmentEditorActionKey)}
onClose={closeDepartmentEditor}
onSave={handleDepartmentItemSave}
/>
<ImportConfirmDialog
open={Boolean(importContext)}
file={importFile}
......
......@@ -167,8 +167,9 @@ function changeSearchIndex(params) {
});
}
function loadRecord(name) {
return request(`/record/${name}`, {
function loadRecord(name, calculated = true) {
const query = calculated ? '?calculated=true' : '';
return request(`/record/${name}${query}`, {
method: 'get',
});
}
......@@ -219,6 +220,7 @@ function loadRecords(
keyword,
full,
select,
calculated: true,
},
});
}
......@@ -226,7 +228,10 @@ function loadRecords(
function queryRecords(query) {
return request('/record/query', {
method: 'post',
data: query,
data: {
...(query || {}),
calculated: query?.calculated ?? true,
},
});
}
......
......@@ -96,6 +96,7 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
page: 0,
page_size: QUERY_PAGE_SIZE,
mode: 'full',
calculated: true,
sort: '-updated_at -created_at',
...query,
});
......@@ -111,6 +112,7 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
page,
page_size: QUERY_PAGE_SIZE,
mode: 'full',
calculated: true,
sort: '-updated_at -created_at',
...query,
});
......
......@@ -185,6 +185,135 @@ export function getFieldValue(record, fieldPath) {
return fieldPath.split('.').reduce((current, key) => current?.[key], record);
}
function isFormulaEmpty(value) {
return value === undefined || value === null || value === '';
}
function splitFormulaArguments(value) {
const parts = [];
let current = '';
let depth = 0;
let quote = '';
for (const character of String(value || '')) {
if (quote) {
current += character;
if (character === quote) quote = '';
continue;
}
if (character === "'" || character === '"') {
quote = character;
current += character;
} else if (character === '(') {
depth += 1;
current += character;
} else if (character === ')') {
depth = Math.max(0, depth - 1);
current += character;
} else if (character === ',' && depth === 0) {
parts.push(current.trim());
current = '';
} else {
current += character;
}
}
if (current.trim() || parts.length > 0) parts.push(current.trim());
return parts;
}
function unwrapFormulaCall(expression, functionName) {
const match = String(expression || '').trim().match(
new RegExp(`^${functionName}\\s*\\((.*)\\)$`, 'i'),
);
return match ? match[1] : null;
}
function formulaOperandValue(record, expression) {
const source = String(expression || '').trim();
if (!source) return undefined;
const quoted = source.match(/^(['"])(.*)\1$/s);
if (quoted) return quoted[2];
if (/^null$/i.test(source)) return null;
if (/^true$/i.test(source)) return true;
if (/^false$/i.test(source)) return false;
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(source)) return Number(source);
const productArguments = unwrapFormulaCall(source, 'PRODUCT');
if (productArguments !== null) {
const values = splitFormulaArguments(productArguments).map((item) =>
formulaOperandValue(record, item),
);
if (values.some((value) => isFormulaEmpty(value))) return undefined;
const numbers = values.map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return undefined;
return numbers.reduce((result, value) => result * value, 1);
}
const fieldReference = source.match(/^\[([^\]]+)\]$/);
const fieldName = fieldReference ? fieldReference[1].trim() : source;
const value = getFieldValue(record, fieldName) ?? getFieldValue(record, `metadata.${fieldName}`);
return value && typeof value === 'object' && !Array.isArray(value)
? value.value ?? value._id ?? value.name
: value;
}
/**
* Evaluate the small, declarative formula grammar used by Subject fields.
* It intentionally supports only function calls and literals, never arbitrary
* JavaScript, so formulas from the API cannot execute code in the browser.
*/
export function evaluateFormula(formula, record) {
const source = String(formula || '').trim();
if (!source) return undefined;
const switchArguments = unwrapFormulaCall(source, 'SWITCH');
if (switchArguments !== null) {
const parts = splitFormulaArguments(switchArguments);
if (parts.length < 3) return undefined;
const switchValue = formulaOperandValue(record, parts[0]);
for (let index = 1; index + 1 < parts.length; index += 2) {
const caseValue = formulaOperandValue(record, parts[index]);
if (String(caseValue) === String(switchValue)) {
return formulaOperandValue(record, parts[index + 1]);
}
}
return parts.length % 2 === 0
? formulaOperandValue(record, parts[parts.length - 1])
: undefined;
}
return formulaOperandValue(record, source);
}
export function isFormulaFieldType(fieldType) {
return normalizeText(fieldType).toLowerCase() === 'formula';
}
export function getFormulaExpression(fieldDef) {
return fieldDef?.settings?.formula || fieldDef?.formula || '';
}
export function resolveFieldValue(record, fieldPath, fieldDef) {
const currentValue = getFieldValue(record, fieldPath);
if (!isFormulaFieldType(fieldDef?.type)) return currentValue;
const calculatedValue = evaluateFormula(getFormulaExpression(fieldDef), record);
return calculatedValue === undefined ? currentValue : calculatedValue;
}
export function applyFormulaFields(record, fields = []) {
return (fields || []).reduce((nextRecord, field) => {
if (!isFormulaFieldType(field?.type)) return nextRecord;
const fieldPath = getFieldPath(field);
const value = evaluateFormula(getFormulaExpression(field), nextRecord);
return value === undefined ? nextRecord : setFieldValue(nextRecord, fieldPath, value);
}, JSON.parse(JSON.stringify(record || {})));
}
export function setFieldValue(record, fieldPath, value) {
const nextRecord = JSON.parse(JSON.stringify(record || {}));
const keys = fieldPath.split('.');
......
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