From 268d0a657552ce2a7b581aaeb5c2c24752e005e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sat, 5 Sep 2026 22:45:33 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(views,pages,security):=20=E5=A1=AB?= =?UTF-8?q?=E6=8A=A5=E4=B8=80=E5=B1=8F=E5=A1=AB=E6=95=B0=E5=87=BA=E5=88=86?= =?UTF-8?q?=E6=8F=90=E4=BA=A4,=E6=98=8E=E7=BB=86=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=98=A6=E8=BA=AB,=E5=A0=B5=E4=BD=8F=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE=E4=B8=8E=E7=BC=96=E8=BE=91=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E6=AD=BB=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 填报人打开系统就落在工作台,在同一页填完 3 行、看到分、把单提交掉:实测 7 次点击、 0 次跳页(原动线 11 击 3 跳)。 - 工作台(kpi_home)从一段说明文字改成待办入口:「我的指标填报」是 kpi_entry_line 的 可编辑 object-grid(单击进编辑,攒够了一次「全部保存」),「我的填报单」挂 kpi_sheet_submit 行操作。两张网格都不写页面级 filter —— 看得到哪些行由对象 OWD + 方案发布写入的动态共享规则 + 权限集 readScope 决定,视图筛选是展示范围不是安全边界。 - 填报明细列表(list / unfilled)从 16 列收到 10 列,实际值从第 8 列提到第 5 列; 移出的所属填报单、指标方向、计分方式、调整类型、调整后得分在记录页与导出里仍在。 - 填报明细编辑表单去掉整个「计分」分区:本版本控制台提交表单上的全部字段,而填报人员 对 score / adjusted_score 只读,于是只改一格实际值也被判「写了不该写的字段」 (objectstack-ai/objectstack#15259)。字段不在表单里,提交体就不带它们。留下的业务 字段一律显式 readonly。 - 填报单详情「相关」页签的明细列表显式声明 relatedListColumns:派生出来的六列全是配置项, 填报人看不到自己填了什么、得了多少分。相关列表的列只能声明在关系上(子对象 highlightFields → relatedListColumns → 页面块),视图层没有入口。 - 部门填报人员的 kpi_entry_line.allowCreate 收成 false:明细只由方案发布生成,手工新建的 行没有冻结的目标值与权重,算不出分。导入填的是已生成行的实际值,走 allowEdit,不受影响。 计分口径与状态机一行未动。工作项 #34。 Co-Authored-By: Claude Fable 5.1 --- src/objects/entry.object.ts | 14 +++++++ src/pages/index.ts | 45 ++++++++++++++++++++- src/security/index.ts | 6 ++- src/translations/zh-CN.objects.generated.ts | 3 -- src/views/index.ts | 23 +++++++++-- 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/objects/entry.object.ts b/src/objects/entry.object.ts index 84de41b..343e867 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -147,6 +147,20 @@ export const EntryLine = ObjectSchema.create({ { name: 'remark' }, { name: 'is_adjusted' }, { name: 'adjust_type_applied' }, ], + /** + * 填报单详情「相关」页签里那张填报明细表的列(#34)。 + * + * 不声明时平台从子对象派生,派生出来的是 指标方向 / 计分方式 / 来源下达 / 指标 / + * 指标名称 / 计量单位 六列 —— 全是配置项,目标、权重、实际值、得分一个都没有,填报人 + * 打开「相关」页签看不到自己填了什么、得了多少分。这里按《设计方案》5.4 的指标网格 + * 口径显式列出。 + * + * 只能声明在关系上:相关列表的列取自 子对象 highlightFields → 本键 → 页面块的 + * `record:related_list.columns`,视图层没有这条链的入口(spec `field.zod.ts` 的 + * `relatedListColumns` 说明)。与上面的 `inlineColumns` 不同,这里只收字段名字符串, + * 标签、类型、格式一律从子对象字段定义取,列与字段因此不会各说各话。 + */ + relatedListColumns: ['indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'remark'], }), plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true }), indicator: Field.lookup('kpi_indicator', { label: '指标', readonly: true }), diff --git a/src/pages/index.ts b/src/pages/index.ts index e42cba8..66f5ac7 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,5 +1,22 @@ import { definePage } from '@objectstack/spec/ui'; +/** + * 工作台 —— 填报人员的待办入口(#34)。 + * + * 设计目标是「一屏填数出分提交」:登录后直接落在本页,不再先跳「填报明细」再跳「填报单」。 + * 两个 `object-grid` 区块合起来构成一条不跳页的动线 —— + * + * 1. 「我的指标填报」= `kpi_entry_line` 的可编辑网格(`editable: true`),列只放填报人真正 + * 要看的九项;实际值与备注可改,其余列在字段定义上就是 `readonly`,网格照此渲染。 + * 保存后 `entry-line.hook` 逐行算分,完成率 / 得分率 / 最终得分当场回填到同一张表。 + * 2. 「我的填报单」= `kpi_entry_sheet` 的只读网格,行操作挂 `kpi_sheet_submit`(「提交填报」), + * 填完即可在同一页提交。 + * + * 两张网格都**不带筛选**:能看到哪些行完全由数据层决定(对象 OWD `private` / + * `controlled_by_parent` + 方案发布时写入的动态共享规则 + 权限集 `readScope`),部门填报人员 + * 因此只看得到本部门的行。页面上再加一层 `filter` 只会把「看得见」和「该看见」拆成两处口径, + * 组织一变就对不上 —— 视图筛选是展示范围,不是安全边界。 + */ export const HomePage = definePage({ name: 'kpi_home', label: '工作台', @@ -10,9 +27,33 @@ export const HomePage = definePage({ regions: [ { name: 'header', width: 'full', components: [{ type: 'page:header', properties: { title: 'KPI 考核管理', subtitle: '指标库 → 方案配置 → 指标下达 → 填报 → 并行核对 → 审核 → 计分 → 调整 → 汇总 → 归档' } }] }, { - name: 'main', width: 'large', + name: 'main', width: 'full', components: [ - { type: 'element:text', properties: { content: '部门填报人员:在「填报单」里按行录入实际值,系统即时出分;确认无误后点「提交填报」。分公司核对人员:在「分公司核对」里确认或提出争议。人力审核 / 分管领导:在「填报单」对应队列里审核通过或驳回(驳回必须填写原因)。审批通过后在「结果与归档」查看四个维度的汇总并归档。' } }, + { type: 'element:text', properties: { content: '在「我的指标填报」里直接填实际值,保存即出分;确认无误后在「我的填报单」里点「提交填报」。' } }, + { type: 'element:text', properties: { content: '我的指标填报', variant: 'subheading' } }, + { + type: 'object-grid', + properties: { + objectName: 'kpi_entry_line', + editable: true, + singleClickEdit: true, + columns: ['indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'remark'], + sort: [{ field: 'indicator_name', order: 'asc' }], + pageSize: 25, + }, + }, + { type: 'element:divider', properties: {} }, + { type: 'element:text', properties: { content: '我的填报单', variant: 'subheading' } }, + { + type: 'object-grid', + properties: { + objectName: 'kpi_entry_sheet', + columns: ['name', 'subject', 'status', 'line_count', 'weight_total', 'indicator_score', 'bonus_total', 'total_score'], + rowActions: ['kpi_sheet_submit'], + sort: [{ field: 'updated_at', order: 'desc' }], + pageSize: 25, + }, + }, ], }, ], diff --git a/src/security/index.ts b/src/security/index.ts index 6ea543d..0c9eef6 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -110,7 +110,11 @@ export const DeptReporterPermissionSet = definePermissionSet({ kpi_staff_assignment: readOwn, kpi_personal_item: { allowRead: true, readScope: 'org' }, // 填报单 OWD 为 private:本部门可见性由方案发布时写入的动态共享规则(services/sharing-service.ts)从 own 放宽到本单元 kpi_entry_sheet: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'own', writeScope: 'own' }, - kpi_entry_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true, readScope: 'own', writeScope: 'own' }, + // 填报明细只由方案发布生成:`allowCreate: false` 让「新建」在填报明细列表与填报单 + // 「相关」页签里都不再出现 —— 手工新建一行明细既没有冻结的目标值与权重,也不在任何 + // 指标下达之下,计分引擎算不出分,是一条注定作废的行。导入路径不受影响:导入填的是 + // 已生成行的实际值,走的是 `allowEdit`。 + kpi_entry_line: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: true, allowExport: true, readScope: 'own', writeScope: 'own' }, kpi_check_task: { allowRead: true, readScope: 'own' }, // 审核记录:只见本部门填报单的留痕(共享规则按填报单放宽)。 kpi_review_record: readOwn, diff --git a/src/translations/zh-CN.objects.generated.ts b/src/translations/zh-CN.objects.generated.ts index 6373769..6ccb2d6 100644 --- a/src/translations/zh-CN.objects.generated.ts +++ b/src/translations/zh-CN.objects.generated.ts @@ -423,9 +423,6 @@ export const zhCNTranslations: TranslationData = { _sections: { entry: { label: "填报" - }, - score: { - label: "计分" } } }, diff --git a/src/views/index.ts b/src/views/index.ts index e5fe295..decf53e 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -140,15 +140,32 @@ export const EntrySheetViews = defineView({ }, }); -const lineCols = cols('sheet', 'indicator_name', 'unit', 'direction', 'scoring_method', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'score', 'adjusted_score', 'is_adjusted', 'adjust_type_applied', 'final_score', 'remark'); +/** + * 填报明细列表列(#34):只留填报人当场要看的九项 + 「已调整」标记。 + * + * 移出列表的 所属填报单 / 指标方向 / 计分方式 / 调整类型 / 调整后得分 都是配置或留痕字段 —— + * 填一行数时用不上,却把「实际值」挤到第 8 列开外。它们仍在记录页与导出里,不是删掉。 + */ +const lineCols = cols('indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'is_adjusted', 'remark'); export const EntryLineViews = defineView({ list: { label: '填报明细', type: 'grid', data: obj('kpi_entry_line'), columns: lineCols, inlineEdit: true, exportOptions: XLSX, sort: [{ field: 'indicator_name', order: 'asc' }] }, listViews: { unfilled: { label: '未填实际值', type: 'grid', data: obj('kpi_entry_line'), columns: lineCols, inlineEdit: true, filter: [{ field: 'actual_value', operator: 'is_null' }] }, }, + /** + * 编辑表单只留「实际值」「备注」两个可写字段(#34)。 + * + * 原来的「计分」分区把 完成率 / 得分率 / 指标得分 / 调整后得分 / 最终得分 一并摆进表单, + * 而本版本控制台的记录表单**提交表单上的全部字段**,不只提交改动过的那些:部门填报人员的 + * 字段权限把 `score` / `adjusted_score` 标成不可写,于是只改一格实际值也会被服务端按 + * 「写了不该写的字段」拒掉(平台侧 objectstack-ai/objectstack#15259)。把这些只读字段移出 + * 表单,提交体里就不再带它们 —— 得分照样在记录页与列表里看得到,那是读的地方。 + * + * 留下的业务字段一律显式 `readonly`:「所属填报单」「来源下达」是发布时冻结的归属,表单上 + * 只该显示;不标的话控制台会给 lookup 画出可清除的 ✕,诱导一个必然被拒的写操作。 + */ formViews: { form: { type: 'simple', data: obj('kpi_entry_line'), sections: [ - { name: 'entry', label: '填报', columns: 2, fields: [{ field: 'sheet', required: true }, { field: 'plan_indicator', required: true }, { field: 'indicator_name' }, { field: 'unit' }, { field: 'target_value' }, { field: 'weight' }, { field: 'actual_value' }, { field: 'remark' }] }, - { name: 'score', label: '计分', columns: 2, fields: [{ field: 'direction' }, { field: 'scoring_method' }, { field: 'completion_rate' }, { field: 'score_rate' }, { field: 'score' }, { field: 'adjusted_score' }, { field: 'is_adjusted' }, { field: 'adjust_type_applied' }, { field: 'final_score' }, { field: 'last_adjustment' }, { field: 'calc_trace' }] }, + { name: 'entry', label: '填报', columns: 2, fields: [{ field: 'sheet', readonly: true }, { field: 'plan_indicator', readonly: true }, { field: 'indicator_name', readonly: true }, { field: 'unit', readonly: true }, { field: 'target_value', readonly: true }, { field: 'weight', readonly: true }, { field: 'actual_value' }, { field: 'remark' }] }, ] } }, }); From 6049183051a3e08cd869a844a6bf89b842ad86fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sat, 5 Sep 2026 23:58:58 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(views,security,pages):=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E5=BA=94=E4=BF=AE=E4=B8=89=E6=9D=A1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=96=B0=E5=BB=BA=E6=98=8E=E7=BB=86=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=AD=BB=E8=B7=AF=E3=80=81=E5=A1=AB=E6=8A=A5=E4=BA=BA?= =?UTF-8?q?=E4=B8=8D=E5=BE=97=E5=88=A0=E8=A1=8C=E3=80=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E7=BD=91=E6=A0=BC=E5=8A=A0=E8=AF=86=E5=88=AB=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 `src/views/index.ts`:填报明细唯一的 formView 新建与编辑共用,`sheet` / `plan_indicator` 标 `readonly` 会把两个必填 lookup 在新建时一起禁掉,管理员与人力审核 点「新建」永远提交不了。改标 `immutable`(spec ui/view.zod.ts:Editable on create, locked once the record exists),实测管理员、人力审核各新建一条明细成功。实测边界写进 注释:本版控制台只落实前半句,编辑弹窗里这两个 lookup 仍可改(与改动前主干一致,不是新 开的口子),真正的护栏是 `writeScope: 'own'`;另一条路「给新建单独一个 formViews.create」 也实测过,控制台的「新建」仍渲染 formViews.form,不按名字分流。 F2 `src/security/index.ts`:`kpi_entry_line.allowDelete` 与 `allowCreate` 一起关。 删除守卫只在填报单离开「填报中」后才拦,留着删除权等于允许填报人在填报期删掉发布生成的 行,而新建已关、提交闸门只数「实际值为空」的行 —— 缺行的单能干净地提交,权重合计与指标 得分静默少掉一块。实测:研发工程师(填报单仍在填报中)DELETE 与 POST 均 403 PERMISSION_DENIED;销售经理的填报明细列表行操作单元格为空、记录页操作菜单只剩「分享」。 F3 `src/pages/index.ts`:工作台可编辑网格补「所属填报单」为首列并以它打头排序 —— 跨方案 / 跨期间的同名指标靠它区分归属;区块标题改用术语表的表内名词「待填报的指标」「填报单」, 不再用「我的」(默认落地页六个岗位共用);说明文字收成一行但保留四类岗位各自的入口指引 (评审 R4)。两条做不到的按实记录在注释里:① 「只显示填报中的单的明细」要跨关系过滤, 查询引擎明确拒绝(`$filter` 写 sheet.status → 400 INVALID_FIELD,提示反规范化后再过滤), 本对象上没有编码填报单状态的字段,退化条件(如「实际值为空」)会让保存后的行当场消失、 连带毁掉「同页出分」,故不做;② 「对 org 岗位隐藏该网格」实测 `visibleWhen` 写 `'kpi_dept_reporter' in current_user.positions` 后连部门填报人员本人都看不到网格,已回退。 Co-Authored-By: Claude Fable 5.1 --- src/pages/index.ts | 48 +++++++++++++++++++++++++++++++------------ src/security/index.ts | 6 +++++- src/views/index.ts | 19 ++++++++++++++--- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/pages/index.ts b/src/pages/index.ts index 66f5ac7..0af53c0 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -6,16 +6,38 @@ import { definePage } from '@objectstack/spec/ui'; * 设计目标是「一屏填数出分提交」:登录后直接落在本页,不再先跳「填报明细」再跳「填报单」。 * 两个 `object-grid` 区块合起来构成一条不跳页的动线 —— * - * 1. 「我的指标填报」= `kpi_entry_line` 的可编辑网格(`editable: true`),列只放填报人真正 - * 要看的九项;实际值与备注可改,其余列在字段定义上就是 `readonly`,网格照此渲染。 - * 保存后 `entry-line.hook` 逐行算分,完成率 / 得分率 / 最终得分当场回填到同一张表。 - * 2. 「我的填报单」= `kpi_entry_sheet` 的只读网格,行操作挂 `kpi_sheet_submit`(「提交填报」), + * 1. 「待填报的指标」= `kpi_entry_line` 的可编辑网格(`editable: true`),首列是「所属填报单」, + * 后面九列是填一行数当场要看的项;实际值与备注可改,其余列在字段定义上就是 `readonly`, + * 网格照此渲染。保存后 `entry-line.hook` 逐行算分,完成率 / 得分率 / 最终得分当场回填到 + * 同一张表。 + * 2. 「填报单」= `kpi_entry_sheet` 的只读网格,行操作挂 `kpi_sheet_submit`(「提交填报」), * 填完即可在同一页提交。 * - * 两张网格都**不带筛选**:能看到哪些行完全由数据层决定(对象 OWD `private` / - * `controlled_by_parent` + 方案发布时写入的动态共享规则 + 权限集 `readScope`),部门填报人员 - * 因此只看得到本部门的行。页面上再加一层 `filter` 只会把「看得见」和「该看见」拆成两处口径, - * 组织一变就对不上 —— 视图筛选是展示范围,不是安全边界。 + * **能看到哪些行**由数据层决定(对象 OWD `private` / `controlled_by_parent` + 方案发布时 + * 写入的动态共享规则 + 权限集 `readScope`)—— 视图筛选是展示范围,不是安全边界,这里不拿 + * `filter` 当权限用。 + * + * **屏幕上怎么分辨哪一行是哪一张单**是另一回事(评审 F3),三处按平台实际能到的程度处理: + * + * - 首列 `sheet`(所属填报单)是识别列,`sort` 也以它打头:跨方案 / 跨期间的同名指标 + * (两行「回款率」)靠它区分归属并排到一起,没有它,单击即改的网格里写错行不会有任何提示。 + * - 「只显示填报中的单的明细」**做不到**,不是没做:这是 `kpi_entry_sheet.status`,而查询 + * 引擎明确拒绝跨关系过滤 —— `$filter` 上写 `sheet.status` 返回 400 `INVALID_FIELD`: + * 「filters on 'sheet.status', which follows the relationship 'sheet' into another object + * — a filter reaches only columns of 'kpi_entry_line' itself … Denormalise the value onto + * 'kpi_entry_line' (a stored field, written when the source changes) and filter that.」 + * 本对象上没有任何字段编码填报单状态,而反规范化要新增字段 + 写入路径,超出本工作项范围。 + * 退化的替代条件(如「实际值为空」)会让保存后的行当场从网格里消失,连带毁掉「同页出分」, + * 比不过滤更糟,因此不做。已提交 / 已归档的行仍会出现在网格里,改动在保存时被 `sheet.hook` + * 的冻结闸门拦下并给出三段式提示 —— 拦得住,只是晚一步。 + * - 「对 org 范围岗位隐藏本区块」**做不到**:页面组件的 `visibleWhen` 只绑 `record` / + * `current_user` / `page.`(spec `ui/page.zod.ts`),没有岗位绑定。实测写 + * `'kpi_dept_reporter' in current_user.positions` 后,**连部门填报人员本人都看不到网格** + * (谓词求值为空 = 对所有人隐藏),已回退。 + * + * 区块标题按需求 §1.4 术语表用「填报单」「指标」的表内名词,不用「我的」——`kpi_home` 是 + * `isDefault: true` 的默认落地页,六个岗位共用;对管理员 / 人力审核这类 org 范围岗位, + * 网格里本来就不止「我的」行,标题不该替它们说话。 */ export const HomePage = definePage({ name: 'kpi_home', @@ -29,21 +51,21 @@ export const HomePage = definePage({ { name: 'main', width: 'full', components: [ - { type: 'element:text', properties: { content: '在「我的指标填报」里直接填实际值,保存即出分;确认无误后在「我的填报单」里点「提交填报」。' } }, - { type: 'element:text', properties: { content: '我的指标填报', variant: 'subheading' } }, + { type: 'element:text', properties: { content: '部门填报人员:在下方「待填报的指标」直接填实际值,保存即出分,再到「填报单」点「提交填报」;分公司核对人员:在「分公司核对」确认或提出争议;人力审核 / 分管领导:在「填报单」对应队列里审核通过或驳回(驳回必须填写原因);审批通过后在「结果与归档」查看四个维度的汇总并归档。' } }, + { type: 'element:text', properties: { content: '待填报的指标', variant: 'subheading' } }, { type: 'object-grid', properties: { objectName: 'kpi_entry_line', editable: true, singleClickEdit: true, - columns: ['indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'remark'], - sort: [{ field: 'indicator_name', order: 'asc' }], + columns: ['sheet', 'indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'remark'], + sort: [{ field: 'sheet', order: 'asc' }, { field: 'indicator_name', order: 'asc' }], pageSize: 25, }, }, { type: 'element:divider', properties: {} }, - { type: 'element:text', properties: { content: '我的填报单', variant: 'subheading' } }, + { type: 'element:text', properties: { content: '填报单', variant: 'subheading' } }, { type: 'object-grid', properties: { diff --git a/src/security/index.ts b/src/security/index.ts index 0c9eef6..4205bd0 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -114,7 +114,11 @@ export const DeptReporterPermissionSet = definePermissionSet({ // 「相关」页签里都不再出现 —— 手工新建一行明细既没有冻结的目标值与权重,也不在任何 // 指标下达之下,计分引擎算不出分,是一条注定作废的行。导入路径不受影响:导入填的是 // 已生成行的实际值,走的是 `allowEdit`。 - kpi_entry_line: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: true, allowExport: true, readScope: 'own', writeScope: 'own' }, + // `allowDelete` 与它一起关:删除守卫(EntryLineDeleteGuardHook)只在填报单离开 + // 「填报中」之后才拦,留着删除权就等于允许填报人在填报期删掉发布生成的行 —— 新建已关, + // 删掉就没有任何重建路径,而提交闸门只数「实际值为空」的行,缺行的单能干净地提交, + // 权重合计与指标得分静默少掉一块。明细的增删只由方案发布与数据调整负责。 + kpi_entry_line: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'own', writeScope: 'own' }, kpi_check_task: { allowRead: true, readScope: 'own' }, // 审核记录:只见本部门填报单的留痕(共享规则按填报单放宽)。 kpi_review_record: readOwn, diff --git a/src/views/index.ts b/src/views/index.ts index decf53e..76eb144 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -161,11 +161,24 @@ export const EntryLineViews = defineView({ * 「写了不该写的字段」拒掉(平台侧 objectstack-ai/objectstack#15259)。把这些只读字段移出 * 表单,提交体里就不再带它们 —— 得分照样在记录页与列表里看得到,那是读的地方。 * - * 留下的业务字段一律显式 `readonly`:「所属填报单」「来源下达」是发布时冻结的归属,表单上 - * 只该显示;不标的话控制台会给 lookup 画出可清除的 ✕,诱导一个必然被拒的写操作。 + * 「所属填报单」「来源下达」标 `immutable` 而不是 `readonly`(评审 F1)。两者在对象上都是 + * `required: true`,而这是**唯一**一个 formView,新建与编辑共用:标 `readonly` 时控制台把 + * 两个必填 lookup 的「选择…」按钮一起 `disabled`,管理员与人力审核点「新建」只能得到 + * 「所属填报单不能为空、来源下达不能为空」,表单永远提交不了。`immutable`(spec + * `ui/view.zod.ts`:Editable on create, locked once the record exists)正是为这两种场景 + * 分开设的键,换上后管理员实测新建成功。 + * + * ⚠️ 实测边界,别按字面理解:本版控制台**只落实了 `immutable` 的前半句**。编辑弹窗里这两个 + * lookup 的「选择…」按钮仍是 enabled(`button.disabled === false`),没有「记录存在后锁死」 + * 的表现。也就是说,归属字段在编辑表单上并没有 UI 锁 —— 这与改动前主干上的行为一致 + * (主干这两个字段只有 `required`,同样可改),不是本次新开的口子;真正的护栏在数据层: + * `writeScope: 'own'` 让填报人只够得到自己那张单。另一条路(给「新建」单独一个 + * `formViews.create`)也实测过:控制台的「新建」仍然渲染 `formViews.form`,新建与编辑 + * 不按名字分流,因此这条路不成立。其余业务字段在对象定义上本就是 `readonly`,这里的标记 + * 只是让表单显式一致。 */ formViews: { form: { type: 'simple', data: obj('kpi_entry_line'), sections: [ - { name: 'entry', label: '填报', columns: 2, fields: [{ field: 'sheet', readonly: true }, { field: 'plan_indicator', readonly: true }, { field: 'indicator_name', readonly: true }, { field: 'unit', readonly: true }, { field: 'target_value', readonly: true }, { field: 'weight', readonly: true }, { field: 'actual_value' }, { field: 'remark' }] }, + { name: 'entry', label: '填报', columns: 2, fields: [{ field: 'sheet', immutable: true }, { field: 'plan_indicator', immutable: true }, { field: 'indicator_name', readonly: true }, { field: 'unit', readonly: true }, { field: 'target_value', readonly: true }, { field: 'weight', readonly: true }, { field: 'actual_value' }, { field: 'remark' }] }, ] } }, }); From 524f8b130e3fda5f5f5033450677666738d5137d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 00:56:32 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(objects):=20=E5=A1=AB=E6=8A=A5=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E7=9A=84=E5=BD=92=E5=B1=9E=E4=B8=A4=E9=94=AE=E5=BB=BA?= =?UTF-8?q?=E5=AE=8C=E9=94=81=E5=AE=9A=20=E2=80=94=E2=80=94=20=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E5=B1=82=20readonlyWhen=20=E5=85=9C=E5=BA=95=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=B1=82=20immutable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复查轮 F1 残余:`sheet` / `plan_indicator` 在编辑弹窗里仍可改、仍带 ✕。 视图层 `immutable: true` 在本版控制台的编辑表单上只落实了前半句(新建可填), 后半句(建完锁定)没生效,已上报 objectstack-ai/objectstack#16171 等平台修复。 应用侧兜底:在对象层给这两个字段各加一行 `readonlyWhen: 'record.id != null'`。 新记录没有 id、谓词判假,两个 lookup 照常可选;已存在的记录判真,编辑面渲染成 只读文本。平台约定 readonlyWhen 不在创建路径上锁字段,hook 与种子的建行路径不受 影响。视图层的 `immutable` 保留不动,平台修好后两者语义一致、可共存。 Co-Authored-By: Claude Fable 5.1 --- src/objects/entry.object.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/objects/entry.object.ts b/src/objects/entry.object.ts index 343e867..3209ab7 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -121,9 +121,31 @@ export const EntryLine = ObjectSchema.create({ nameField: 'indicator_name', fields: { + /** + * 归属两键(`sheet` / `plan_indicator`)的「新建可填、建完锁定」(#34)。 + * + * 这两个键决定一行明细挂在哪张填报单、对应哪条下达指标 —— 新建时必须能选(否则 + * 「新建」是死路),建成之后改任何一个都等于把这行悄悄挪走,连带 指标名称 / + * 计量单位 / 目标值 / 权重 四个冻结字段与已算出的得分全部对不上账。 + * + * `readonlyWhen: 'record.id != null'` —— 新记录没有 id,谓词判假,两个 lookup 正常 + * 可选;已存在的记录判真,编辑面渲染为只读文本。声明在对象层,所有展示面(记录 + * 编辑弹窗、内嵌网格、相关列表)一致生效;平台约定 `readonlyWhen` 不在创建路径上 + * 锁字段,所以 hook 与种子的建行路径不受影响。 + * + * 与视图层 `immutable` 的分工:`src/views/index.ts` 的表单里这两个字段标了 + * `immutable: true`(语义即「可建不可改」),但本版控制台只落实了它的前半句 —— + * 编辑弹窗里仍渲染带 ✕ 的选择器。**视图层 immutable 待平台修复** + * (objectstack-ai/objectstack#16171),**对象层 readonlyWhen 为应用侧兜底**; + * 平台修好后两者语义一致、可共存,不必回删。 + * + * 注意这是 UI/写入面的护栏而非权限闸门:数据层真护栏仍是 `src/security/index.ts` + * 的 `writeScope` 与 `src/hooks/entry-line.hook.ts` 的冻结守卫。 + */ sheet: Field.masterDetail('kpi_entry_sheet', { label: '所属填报单', required: true, + readonlyWhen: 'record.id != null', deleteBehavior: 'cascade', inlineEdit: 'grid', inlineTitle: '指标填报', @@ -162,7 +184,8 @@ export const EntryLine = ObjectSchema.create({ */ relatedListColumns: ['indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'remark'], }), - plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true }), + /** 建完锁定,理由与 `sheet` 上方那段注释同源(#34)。 */ + plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true, readonlyWhen: 'record.id != null' }), indicator: Field.lookup('kpi_indicator', { label: '指标', readonly: true }), indicator_name: Field.text({ label: '指标名称', readonly: true, searchable: true, maxLength: 200 }), unit: Field.text({ label: '计量单位', readonly: true, maxLength: 20 }),