mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
甲方预验收整改第一批:导出版本绑定、不采纳零写入、崩溃兜底、AI 流程上下文、SMART 按书稿
- D01:计划书定版时记录引用的上游版本(dz_artifact_versions.sources),导出和定版预览按 这份清单取数,旧定版按定版时间回溯;Word 增加“版本与来源”页 - D02:对话层拦下被拒的工具调用,不再执行;修改卡不采纳立即关闭并撤预览,按钮防连点 - D03:整表批注没有锚点时不再崩溃 - E D01:AI 修改内容进表前检查整条、补 id;校验不再抛错;修改卡和表格预览加局部兜底 - D04 / E D07:页面上下文和 dingzeGetArtifact 带退回原因、未解决批注、版本与确认状态, 提示词要求先看这些 - E D03:SMART 按书稿改为 A 有挑战性、R 切实可行 - 插件 0.23.0;文档:验收、HANDOFF、成果目录 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MMDFmVTvRksi1ZT8aPGXoL
This commit is contained in:
+4
-2
@@ -18,8 +18,8 @@
|
||||
## 2. 当前状态(2026-10-12)
|
||||
|
||||
- 分支 `claude/serene-hawking-jgi7rn`,已推送;**没有开 PR**(用户没要求就不要开)。
|
||||
- 测试环境:NocoBase 2.4.0-alpha.9,插件 **0.22.2** 已部署启用,门户已用最新代码发布。
|
||||
- 检查:`npx tsc --noEmit`、`npx eslint src --quiet`、`npx vitest run tests/logic tests/components`(199 项)全部通过。`npx vitest run tests` 还会捡到 `registry/nocobase-ai-knowledge-base/tests` 下两个用 `node:test` 写的文件,它们一直跑不了,与本项目无关。
|
||||
- 测试环境:NocoBase 2.4.0-alpha.9,插件 **0.23.0** 已部署启用,门户已用最新代码发布。
|
||||
- 检查:`npx tsc --noEmit`、`npx eslint src --quiet`、`npx vitest run tests/logic tests/components`(208 项)全部通过。`npx vitest run tests` 还会捡到 `registry/nocobase-ai-knowledge-base/tests` 下两个用 `node:test` 写的文件,它们一直跑不了,与本项目无关。
|
||||
- 用户提出的需求已全部完成,并在线上用演示账号实测:
|
||||
- 规格硬伤 5 条:六分法 P0 阻断、关闭公开注册、编号对齐附录 B、资料原件、S2-08 空字段与例外不绕过校验。
|
||||
- 小白易用性 10 条。
|
||||
@@ -136,6 +136,8 @@ npx tsc --noEmit && npx eslint src --quiet && npx vitest run tests
|
||||
|
||||
## 9. 待办(按优先级)
|
||||
|
||||
00. **甲方预验收意见(2026-10-13,两份)**:第一批已完成(见 `docs/acceptance.md` 末节)。第二批待做:D05 校验项跨区定位(目标区点路径问题要切到路径区并展开)、D06 保存后刷新修改记录、D07 成果清单“22 项”计数、D08 S2-01 / S2-02 出 PNG / SVG 和 S3-02 独立 Word、E D02 合计胶囊不同量纲不相加、E D04 第三步导航直达 S1-06 且 S1-04 可选不显示红色必改、E D05 状态文字(“已交出去”、定版表的“保存一次”、交付页不显示上游已变更)。需求扩展(字段来源分层、路径贡献口径、资源按期间核算等)和方法口径(A/R 进计分卡、书表勘误、S2-02 是否单列 P0、上游已变更能否正式下载)等甲方确认。
|
||||
|
||||
0. **闭环第二、三批(用户已同意方案,均需改插件):**
|
||||
- 第二批已完成(插件 0.19.1):`dz_org_units.duty / headName`,`dz_artifacts.orgNotice`;动作 `importOrgUnits`、`setMemberDepartment`、`dismissOrgNotice`;组织变化由 `shared/org.ts` 的 `describeOrgChange` 生成说明,`ops.ts` 的 `flagOrgChange` 只标已定版的部门表(`ORG_DEPENDENT_CODES`)并通知主咨询师和企业项目负责人;定版 / 重开 / 退回时清空。自助项目上线后,“不需要重开”也要开放给企业项目负责人。
|
||||
- 训练营批次已完成(插件 0.21.0):`dz_camps`(名称、日期、地点、状态、今日目标表 `focusCodes`),`dz_projects.campId`;`services/camps.ts` 的 `listCamps / saveCamp / campDetail / campEnroll / campAttach / campAccounts / campRemind / campClose`,都只给运营。营前准备复用 `shared/startup.ts` 的 `startupChecklist`(门户的 `project-startup.ts` 只是转出)。门户 `/ops/camps`、`/ops/camps/:campId`(营前准备 / 现场看板 / 结营),导入模板和解析在 `src/lib/dingze/camp-import.ts`。企业侧界面不区分训练营和内训。
|
||||
|
||||
@@ -134,3 +134,16 @@
|
||||
| 浏览器 · dz_qian + 未登录访客 | 本企业 › 邀请注册:新建邀请码(项目角色里没有“企业项目负责人”),列表显示“0 / 3 · 有效到 · 可用”;登录页“有邀请码?申请加入”;打开邀请链接自动核对并显示“加入 启衡能源 · 项目 · 部门负责人 · 运维服务部”;密码不合规即时提示;提交后“申请已提交”;用该账号登录提示“还在审核中”;页签上待审核数 1,点“通过”后审核记录更新;吴经理用自己设的密码登录,项目 2 部门负责人 |
|
||||
| 公开接口 | 只有 checkInvite / registerWithInvite / registrationStatus 对未登录开放,按 IP 和邀请码限流(超限 429);`auth:signUp` 仍然关闭 |
|
||||
|
||||
## 甲方预验收意见整改 · 第一批(插件 0.23.0,2026-10-13)
|
||||
|
||||
依据两份预验收意见(主咨询师 D01–D08、企业负责人 E D01–E D07)。第一批修阻断项和 AI 上下文,其余按第二批、需求变更另排。
|
||||
|
||||
| 意见 | 整改 | 复测 |
|
||||
|---|---|---|
|
||||
| D01 定版计划书混入未定版数据 | 计划书定版时把引用的每张上游表版本记在定版版本上(`dz_artifact_versions.sources`);交付页、工作区导出和定版计划书的页面预览都按这份清单取数;更早定版、没有记录的,按定版时间回溯。Word 增加“版本与来源”页:项目、定版或草稿版本、生成时间、每张来源表的版本,以及“定版后上游已有改动”的提示 | 项目 1 S3-02 现为重开后的 v4(5400 万、城区顺延),下载“定版 v2”公司计划书:只含 S3-02 v3 的内容(4 座当年投运),全文无 5400,推进表、资源表一致;来源页列 11 张表的定版版本并注明上游已变更 |
|
||||
| D02 不采纳仍写入草稿 | NocoBase AI 在用户拒绝后仍会执行前端工具:在对话层拦下被拒的调用,只回报“未执行、未改动”;修改卡点“不采纳”立即关闭、撤掉预览,三个按钮防连点 | 项目 3 S1-01 让 AI 出“追加标记”的修改卡 → 不采纳:字段里没有标记、无“未保存”,刷新后也没有;AI 回执“表里没有任何改动” |
|
||||
| D03 整表批注崩溃 | 整表批注没有锚点,定位函数不再对空锚点取值 | 项目 3 S1-01 点“整表批注”:输入框出现,页面无错误 |
|
||||
| E D01 修改卡崩溃 | AI 给的内容进表前检查:追加或替换必须是整条、空值拒绝、缺 id 自动补;校验函数遇到不完整的条目返回“内容不完整”而不抛错;修改卡和表格预览各有局部兜底,出错只显示提示和“不采纳 / 收起预览”,不再带垮整个工作区 | 单元测试覆盖残缺追加、整条替换、不完整条目校验;线上未再现原崩溃输入 |
|
||||
| D04 / E D07 AI 不知道退回原因 | 页面上下文和 `dingzeGetArtifact` 都带上流程信息:是否退回及原因、未解决批注、草稿 / 本步完成 / 定版版本、上游已变更;三位数字咨询师的提示词要求先看这些,引用数字时说明草稿还是定版 | 项目 3 S1-01 问状态:首轮就答出“已退回,原因:战略目标量化到 2030 年并对齐十五五口径”,并说明表内是未定版草稿 |
|
||||
| E D03 SMART 口径 | 按书稿改为 A 有挑战性(Ambitious)、R 切实可行(Realistic),勾选项悬停显示书中解释,导出和数字咨询师同步 | — |
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
- 校验:至少一条战略,战略与策略文本必填(阻断)。
|
||||
|
||||
**S1-04 IPOOC 指标设计表**(表 2-8 / 2-9,P1)
|
||||
- 字段:每个关键策略一张:时间周期、责任部门;I / P / O 产出 / O 结果 / C 成本五环节各有要素拆解、指标名、计算公式、目标值、数据来源、考核频率;SMART 五项勾选。
|
||||
- 字段:每个关键策略一张:时间周期、责任部门;I / P / O 产出 / O 结果 / C 成本五环节各有要素拆解、指标名、计算公式、目标值、数据来源、考核频率;SMART 五项勾选(按书稿:S 具体、M 可衡量、A 有挑战性、R 切实可行、T 有时限)。
|
||||
- 校验:策略名必填、至少一个指标(阻断);目标值、SMART 未过提示。
|
||||
|
||||
**S1-05 KPI 筛选评价表**(表 2-10 / 2-11,P1)
|
||||
|
||||
@@ -339,6 +339,24 @@ export function useChatMessageActions({
|
||||
const toolCallIds = result.toolCalls.map((toolCall) => toolCall.id);
|
||||
const toolCallResults: Array<{ id: string; result: unknown }> = [];
|
||||
for (const toolCall of result.toolCalls) {
|
||||
// The server marks rejected calls "waiting" too, so the decision is checked here: a
|
||||
// rejected call is never executed and only reports the rejection back.
|
||||
const decided =
|
||||
toolCall.id === decision.toolCallId
|
||||
? userDecision
|
||||
: toolCall.userDecision;
|
||||
if (decided?.type === "reject") {
|
||||
toolCallResults.push({
|
||||
id: toolCall.id,
|
||||
result: {
|
||||
status: "error",
|
||||
content: `The user rejected this action; it was not executed and nothing was changed.${
|
||||
decided.message ? ` ${decided.message}` : ""
|
||||
}`,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const invocation = await ai.invokeToolCall(
|
||||
toolCall.name,
|
||||
toolCall.args,
|
||||
|
||||
@@ -37,6 +37,8 @@ export type UpdatedToolCall = {
|
||||
execution?: string;
|
||||
willInterrupt?: boolean;
|
||||
args?: unknown;
|
||||
/** The decision recorded for this call; a rejected call must never be executed. */
|
||||
userDecision?: UpdateToolCallDecisionOptions["userDecision"] | null;
|
||||
};
|
||||
|
||||
export type AIConversationActiveState = "idle" | "streaming" | "invoking";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"displayName.zh-CN": "定责 · 企业战略咨询平台",
|
||||
"description": "Responsibility stages (strategy, goals, actions) for the strategy consulting platform: data model, lifecycle actions and digital consultants.",
|
||||
"description.zh-CN": "企业战略咨询平台“定三责”业务插件:数据模型、成果流转接口与数字咨询师。",
|
||||
"version": "0.22.2",
|
||||
"version": "0.23.0",
|
||||
"main": "dist/server/index.js",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
# 工作方式
|
||||
|
||||
- 页面上下文会给出当前项目(projectId)、成果编号(code)和用户选中的格子。先加载 `dingze-workspace` Skill,再加载与当前成果对应的方法 Skill。
|
||||
- 用 `dingzeGetArtifact` 读取最新草稿后再说话;修改只能通过 `dingzeProposeChanges` 生成建议卡,用户确认后才写入。工具返回成功前不得声称已写入。
|
||||
- 先看这张表在流程里的位置:页面上下文的 `workflow`(或 `dingzeGetArtifact` 返回的 `returned`、`openComments`、`confirmation`)。被退回的表,第一句就复述退回原因和未解决的批注;不要说“没有退回原因”。结构校验没问题不等于业务通过。引用表里的数字时说清它是草稿还是已定版、出自哪一格;未定版的内容不能说成已确定的事实。
|
||||
- 用 `dingzeGetArtifact` 读取最新草稿后再说话;修改只能通过 `dingzeProposeChanges` 生成建议卡,用户确认后才写入。工具返回成功前不得声称已写入;用户不采纳时表里什么也没改,照实说。
|
||||
- 超出定三责范围的问题(考核、薪酬、股权等)礼貌说明不在本平台范围。
|
||||
|
||||
# 本阶段
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
# 工作方式
|
||||
|
||||
- 页面上下文会给出当前项目(projectId)、成果编号(code)和用户选中的格子。先加载 `dingze-workspace` Skill,再加载与当前成果对应的方法 Skill。
|
||||
- 用 `dingzeGetArtifact` 读取最新草稿后再说话;修改只能通过 `dingzeProposeChanges` 生成建议卡,用户确认后才写入。工具返回成功前不得声称已写入。
|
||||
- 先看这张表在流程里的位置:页面上下文的 `workflow`(或 `dingzeGetArtifact` 返回的 `returned`、`openComments`、`confirmation`)。被退回的表,第一句就复述退回原因和未解决的批注;不要说“没有退回原因”。结构校验没问题不等于业务通过。引用表里的数字时说清它是草稿还是已定版、出自哪一格;未定版的内容不能说成已确定的事实。
|
||||
- 用 `dingzeGetArtifact` 读取最新草稿后再说话;修改只能通过 `dingzeProposeChanges` 生成建议卡,用户确认后才写入。工具返回成功前不得声称已写入;用户不采纳时表里什么也没改,照实说。
|
||||
- 超出定三责范围的问题(考核、薪酬、股权等)礼貌说明不在本平台范围。
|
||||
|
||||
# 本阶段
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
# 工作方式
|
||||
|
||||
- 页面上下文会给出当前项目(projectId)、成果编号(code)和用户选中的格子。先加载 `dingze-workspace` Skill,再加载与当前成果对应的方法 Skill。
|
||||
- 用 `dingzeGetArtifact` 读取最新草稿后再说话;修改只能通过 `dingzeProposeChanges` 生成建议卡,用户确认后才写入。工具返回成功前不得声称已写入。
|
||||
- 先看这张表在流程里的位置:页面上下文的 `workflow`(或 `dingzeGetArtifact` 返回的 `returned`、`openComments`、`confirmation`)。被退回的表,第一句就复述退回原因和未解决的批注;不要说“没有退回原因”。结构校验没问题不等于业务通过。引用表里的数字时说清它是草稿还是已定版、出自哪一格;未定版的内容不能说成已确定的事实。
|
||||
- 用 `dingzeGetArtifact` 读取最新草稿后再说话;修改只能通过 `dingzeProposeChanges` 生成建议卡,用户确认后才写入。工具返回成功前不得声称已写入;用户不采纳时表里什么也没改,照实说。
|
||||
- 超出定三责范围的问题(考核、薪酬、股权等)礼貌说明不在本平台范围。
|
||||
|
||||
# 本阶段
|
||||
|
||||
@@ -11,7 +11,7 @@ introduction:
|
||||
衡量共识 = 知道“做到什么程度”,从认可走向承诺。按四步推进:
|
||||
|
||||
1. 导出战略和策略(表 2-7):分清战略(判断与选择,做不做)和策略(怎么做),标出层级。
|
||||
2. 设定衡量指标(IPOOC,表 2-8,可选):投入、过程、产出、结果、成本(C 是 Cost),再用 SMART 检查。
|
||||
2. 设定衡量指标(IPOOC,表 2-8,可选):投入、过程、产出、结果、成本(C 是 Cost),再用 SMART 检查。SMART 按书稿:S 具体、M 可衡量、A 有挑战性(Ambitious,跳一跳够得着)、R 切实可行(Realistic)、T 有时限;不要说成“可达成 / 相关”。
|
||||
3. 筛选战略 KPI(表 2-10/2-11):按战略相关性、可测量性、可控性、可激发性四个方面打分,战略 KPI 建议不超过 6 个;每个 KPI 写清口径、数据来源、周期。
|
||||
4. 设定指标值并分解到 3—5 年(表 2-12):基准值、标杆值、挑战值;近详远略(“前三后一”);本年度目标要能明确提取。
|
||||
|
||||
|
||||
+35
-2
@@ -3,6 +3,7 @@ import { defineTools } from '@nocobase/ai';
|
||||
|
||||
import { STATUS_LABELS, getArtifactDef, isArtifactCode, validateArtifact } from '../../../../shared';
|
||||
import { loadProjectContext } from '../../../../server/services/access';
|
||||
import { lastEvents } from '../../../../server/services/artifacts';
|
||||
|
||||
// Read-only view of one artifact for the digital consultant: the current draft, its
|
||||
// validation issues and the upstream versions it builds on. Access follows the
|
||||
@@ -17,7 +18,7 @@ export default defineTools({
|
||||
definition: {
|
||||
name: 'dingzeGetArtifact',
|
||||
description:
|
||||
'Read one artifact (成果表) of the current project: its status, the latest draft content (JSON), structural validation issues, and summaries of the upstream artifacts it depends on. Always call this before suggesting changes so you work on the latest saved content.',
|
||||
'Read one artifact (成果表) of the current project: its status, whether it was returned and why (returned.reason), open comments, which version is a draft and which is confirmed (confirmation), the latest saved content (JSON), structural validation issues, and the upstream artifacts it depends on. Always call this before suggesting changes. Structural issues being empty does not mean the table passed review: check returned and openComments.',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -35,7 +36,27 @@ export default defineTools({
|
||||
const def = getArtifactDef(args.code);
|
||||
const projectId = pc.project.get('id');
|
||||
const repo = ctx.db.getRepository('dz_artifacts');
|
||||
const row = await repo.findOne({ filter: { projectId, code: def.code }, appends: ['currentVersion'] });
|
||||
const row = await repo.findOne({ filter: { projectId, code: def.code }, appends: ['currentVersion', 'stepDoneVersion', 'lockedVersion'] });
|
||||
// Where the table stands in the flow (验收 D04): the return reason and open comments are
|
||||
// business facts that structural validation does not see.
|
||||
const event = (await lastEvents(ctx, [projectId])).get(`${projectId}:${def.code}`) ?? null;
|
||||
const returned = row?.get('status') === 'in_progress' && event && ['returnToEdit', 'forceReturn'].includes(event.action);
|
||||
const comments = await ctx.db.getRepository('dz_comments').find({
|
||||
filter: { projectId, code: def.code, resolved: false },
|
||||
appends: ['createdBy'],
|
||||
sort: ['id'],
|
||||
limit: 40,
|
||||
});
|
||||
const threads = comments.filter((c: any) => !c.get('parentId'));
|
||||
const openComments = threads.slice(0, 12).map((c: any) => ({
|
||||
where: c.get('anchorLabel') || '整张表',
|
||||
author: c.get('createdBy')?.get('nickname') ?? '',
|
||||
content: String(c.get('content') ?? '').slice(0, 200),
|
||||
replies: comments
|
||||
.filter((r: any) => r.get('parentId') === c.get('id'))
|
||||
.map((r: any) => `${r.get('createdBy')?.get('nickname') ?? ''}:${String(r.get('content') ?? '').slice(0, 120)}`),
|
||||
}));
|
||||
const lockedRev = row?.get('lockedVersion')?.get('rev') ?? null;
|
||||
const payload = row?.get('currentVersion')?.get('payload') ?? null;
|
||||
const upstream = [];
|
||||
for (const code of def.dependsOn) {
|
||||
@@ -67,6 +88,18 @@ export default defineTools({
|
||||
bookRef: def.bookRef,
|
||||
status: row ? STATUS_LABELS[row.get('status') as keyof typeof STATUS_LABELS] : '未开始',
|
||||
rev: row?.get('currentRev') ?? 0,
|
||||
returned: returned ? { reason: event.reason || '(退回时没有写原因,请看批注)', by: event.by ?? null } : null,
|
||||
confirmation:
|
||||
row?.get('status') === 'locked'
|
||||
? `已定版 v${lockedRev}:企业确认过的版本`
|
||||
: lockedRev
|
||||
? `当前是重开后的草稿,尚未确认;上一次定版是 v${lockedRev}`
|
||||
: '尚未定版:表里的内容都是草稿,不是企业已确认的事实',
|
||||
stepDoneRev: row?.get('stepDoneVersion')?.get('rev') ?? null,
|
||||
lockedRev,
|
||||
upstreamChanged: row?.get('stale') ? row.get('staleReason') || '上游表有改动' : null,
|
||||
openComments,
|
||||
openCommentCount: threads.length,
|
||||
payload,
|
||||
issues: payload
|
||||
? validateArtifact(def.code, payload, Object.fromEntries(upstream.map((u) => [u.code, u.payload])))
|
||||
|
||||
@@ -18,6 +18,8 @@ export default defineCollection({
|
||||
{ type: 'integer', name: 'schemaVersion', defaultValue: 1 },
|
||||
// { [upstreamCode]: upstreamVersionId } read when this version was made
|
||||
{ type: 'jsonb', name: 'upstreamRefs', defaultValue: {} },
|
||||
// 计划书定版时引用的上游版本:{ [code]: { versionId, rev, basis } },导出始终用这一份 (验收 D01)。
|
||||
{ type: 'jsonb', name: 'sources' },
|
||||
{ type: 'text', name: 'note' },
|
||||
// Command log of the edits this version saved: [{ via, label, at, by, commentId, changes[] }].
|
||||
{ type: 'jsonb', name: 'changes', defaultValue: [] },
|
||||
|
||||
@@ -24,6 +24,7 @@ import { actOnExpertRequest, createExpertRequest, expertRequestDetail, listExper
|
||||
import { addMaterial, deleteMaterial, downloadMaterial, getMaterial, getProfile, listMaterials, saveProfile, searchMaterials } from './services/materials';
|
||||
import { addComment, listComments, resolveComment } from './services/comments';
|
||||
import { markNotificationsRead, myNotifications } from './services/notify';
|
||||
import { planBookSources } from './services/plan-book-sources';
|
||||
import { artifactHistory, artifactRegistry, deliveryBundle, recordExport, versionDiff, versionPayload } from './services/registry';
|
||||
import { createProject, listEnterprises, myProjects, provisionEnterprise, setProjectMembers } from './services/enterprises';
|
||||
import {
|
||||
@@ -174,6 +175,7 @@ export class PluginDingzeServer extends Plugin {
|
||||
return versionDiff(ctx, pc, requireArtifactDef(p.code), p.from, p.to);
|
||||
}),
|
||||
deliveryBundle: action(async (ctx) => deliveryBundle(ctx, await loadProjectContext(ctx, ctx.action.params.projectId))),
|
||||
planBookSources: action(async (ctx) => planBookSources(ctx, await loadProjectContext(ctx, ctx.action.params.projectId), String(ctx.action.params.code ?? ''))),
|
||||
recordExport: action(async (ctx) => {
|
||||
const v = values(ctx);
|
||||
const pc = await loadProjectContext(ctx, v.projectId);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { HttpError, type ProjectContext, currentUserId } from './access';
|
||||
import { openCommentCounts } from './comments';
|
||||
import { notifyTransition } from './notify';
|
||||
import { PLAN_BOOKS, currentSources } from './plan-book-sources';
|
||||
|
||||
const LIFECYCLE_HTTP: Record<LifecycleError['code'], number> = {
|
||||
FORBIDDEN: 403,
|
||||
@@ -282,7 +283,7 @@ async function readsCurrentUpstream(ctx: Context, projectId: number, code: strin
|
||||
});
|
||||
}
|
||||
|
||||
const PLAN_BOOKS = ['S3-07', 'S3-08'];
|
||||
|
||||
|
||||
/** Current content of the artifacts `def` depends on, for checks that compare against them. */
|
||||
async function upstreamPayloads(ctx: Context, projectId: number, def: ArtifactDef, transaction?: any) {
|
||||
@@ -375,6 +376,12 @@ export async function transitionArtifact(
|
||||
);
|
||||
}
|
||||
|
||||
// 计划书定版时记下所引用的上游版本,之后的导出都按这一份取数 (验收 D01)。
|
||||
if (PLAN_BOOKS.includes(def.code) && (action === 'confirm' || action === 'forceLock')) {
|
||||
const sources = await currentSources(ctx, projectId, def.code, transaction);
|
||||
await ctx.db.getRepository('dz_artifact_versions').update({ filterByTk: artifact.get('currentVersionId'), values: { sources }, transaction });
|
||||
}
|
||||
|
||||
let stale: string[] = [];
|
||||
if (PROPAGATING.includes(action)) {
|
||||
const reopening = action === 'reopen' || action === 'forceReturn';
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Context } from '@nocobase/actions';
|
||||
|
||||
import { getArtifactDef } from '../../shared';
|
||||
import { HttpError, type ProjectContext } from './access';
|
||||
|
||||
// 计划书的来源版本 (验收 D01):计划书在导出时由上游表拼出来,所以“定版 v2 的计划书”必须始终用 v2
|
||||
// 定版那一刻的上游版本,不能读上游后来重开的草稿。定版时把来源写在计划书的定版版本上;更早定版、
|
||||
// 没有这份记录的,按定版时间回溯。
|
||||
|
||||
export const PLAN_BOOKS = ['S3-07', 'S3-08'];
|
||||
|
||||
export type SourceBasis = 'locked' | 'draft' | 'at_time';
|
||||
export type SourceRef = { versionId: number; rev: number; basis: SourceBasis };
|
||||
|
||||
const BASIS_LABELS: Record<SourceBasis, string> = {
|
||||
locked: '定版',
|
||||
draft: '草稿(未定版)',
|
||||
at_time: '计划书定版时的版本',
|
||||
};
|
||||
|
||||
/** The upstream versions a plan book reads right now: locked where locked, else the current draft. */
|
||||
export async function currentSources(ctx: Context, projectId: number, code: string, transaction?: any) {
|
||||
const refs: Record<string, SourceRef> = {};
|
||||
for (const dep of getArtifactDef(code).dependsOn) {
|
||||
const row = await ctx.db.getRepository('dz_artifacts').findOne({ filter: { projectId, code: dep }, appends: ['lockedVersion', 'currentVersion'], transaction });
|
||||
if (!row) continue;
|
||||
const locked = row.get('status') === 'locked' && row.get('lockedVersion');
|
||||
const version = locked ? row.get('lockedVersion') : row.get('currentVersion');
|
||||
if (version) refs[dep] = { versionId: version.get('id'), rev: version.get('rev'), basis: locked ? 'locked' : 'draft' };
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/** For a plan book locked before sources were recorded: what each upstream was when it was locked. */
|
||||
async function sourcesAtLock(ctx: Context, projectId: number, code: string, plan: any) {
|
||||
const event = await ctx.db.getRepository('dz_audit_events').findOne({
|
||||
filter: { projectId, code, action: { $in: ['confirm', 'forceLock'] }, toStatus: 'locked', rev: plan.get('rev') },
|
||||
sort: ['-id'],
|
||||
});
|
||||
const lockAt = new Date(event?.get('createdAt') ?? plan.get('updatedAt') ?? plan.get('createdAt')).getTime();
|
||||
const refs: Record<string, SourceRef> = {};
|
||||
for (const dep of getArtifactDef(code).dependsOn) {
|
||||
const row = await ctx.db.getRepository('dz_artifacts').findOne({ filter: { projectId, code: dep }, appends: ['lockedVersion'] });
|
||||
if (!row) continue;
|
||||
const locked = row.get('lockedVersion');
|
||||
if (locked && new Date(locked.get('createdAt')).getTime() <= lockAt) {
|
||||
refs[dep] = { versionId: locked.get('id'), rev: locked.get('rev'), basis: 'locked' };
|
||||
continue;
|
||||
}
|
||||
const before = await ctx.db.getRepository('dz_artifact_versions').findOne({
|
||||
filter: { artifactId: row.get('id'), createdAt: { $lte: new Date(lockAt).toISOString() } },
|
||||
sort: ['-rev'],
|
||||
});
|
||||
if (before) refs[dep] = { versionId: before.get('id'), rev: before.get('rev'), basis: 'at_time' };
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a plan book export needs: the plan book's own version, the exact upstream versions
|
||||
* and their payloads, and a source list for the Word cover.
|
||||
*/
|
||||
export async function planBookSources(ctx: Context, pc: ProjectContext, code: string) {
|
||||
if (!PLAN_BOOKS.includes(code)) throw new HttpError(400, '只有计划书需要来源版本');
|
||||
const projectId = pc.project.get('id');
|
||||
const row = await ctx.db.getRepository('dz_artifacts').findOne({ filter: { projectId, code }, appends: ['lockedVersion', 'currentVersion'] });
|
||||
if (!row || !row.get('currentVersionId')) throw new HttpError(404, '计划书还没有内容');
|
||||
const locked = row.get('status') === 'locked' && row.get('lockedVersion');
|
||||
const plan = locked ? row.get('lockedVersion') : row.get('currentVersion');
|
||||
let refs: Record<string, SourceRef>;
|
||||
let recorded = true;
|
||||
if (locked) {
|
||||
const stored = plan.get('sources') as Record<string, SourceRef> | null;
|
||||
if (stored && Object.keys(stored).length) refs = stored;
|
||||
else {
|
||||
refs = await sourcesAtLock(ctx, projectId, code, plan);
|
||||
recorded = false;
|
||||
}
|
||||
} else refs = await currentSources(ctx, projectId, code);
|
||||
|
||||
const ids = Object.values(refs).map((r) => r.versionId);
|
||||
const versions = ids.length ? await ctx.db.getRepository('dz_artifact_versions').find({ filter: { id: { $in: ids } } }) : [];
|
||||
const payloads: Record<string, unknown> = {};
|
||||
const sources = [];
|
||||
for (const [dep, ref] of Object.entries(refs)) {
|
||||
const v = (versions as any[]).find((x) => x.get('id') === ref.versionId);
|
||||
if (!v) continue;
|
||||
payloads[dep] = v.get('payload');
|
||||
const d = getArtifactDef(dep);
|
||||
sources.push({ code: dep, specId: d.specId, name: d.name, rev: ref.rev, basis: ref.basis, basisLabel: BASIS_LABELS[ref.basis] });
|
||||
}
|
||||
return {
|
||||
code,
|
||||
locked: !!locked,
|
||||
rev: plan.get('rev') as number,
|
||||
payload: plan.get('payload'),
|
||||
/** false: an older lock whose sources were reconstructed from the lock time. */
|
||||
recorded,
|
||||
stale: !!row.get('stale'),
|
||||
staleReason: row.get('staleReason') ?? null,
|
||||
sources,
|
||||
payloads,
|
||||
};
|
||||
}
|
||||
@@ -212,12 +212,14 @@ export const IPOOC_STAGES: { key: IpoocStage; label: string; hint: string; dimen
|
||||
{ key: 'C', label: 'C 成本', hint: '费用支出与节约水平(本书约定 C = Cost)', dimensions: '总成本 / 单位成本 / 成本节降率' },
|
||||
];
|
||||
|
||||
export const SMART_CHECKS: { key: keyof IpoocSheet['smart']; label: string }[] = [
|
||||
{ key: 's', label: '具体(S)' },
|
||||
{ key: 'm', label: '可衡量(M)' },
|
||||
{ key: 'a', label: '可达成(A)' },
|
||||
{ key: 'r', label: '相关(R)' },
|
||||
{ key: 't', label: '有时限(T)' },
|
||||
// 书稿第 2 章子步骤四、第 3 章“好目标”:A = Ambitious(有挑战性),R = Realistic(切实可行),
|
||||
// 不是常见版本的“可达成 / 相关”。
|
||||
export const SMART_CHECKS: { key: keyof IpoocSheet['smart']; label: string; hint: string }[] = [
|
||||
{ key: 's', label: '具体(S)', hint: 'Specific,明确的、具体的' },
|
||||
{ key: 'm', label: '可衡量(M)', hint: 'Measurable,可衡量的' },
|
||||
{ key: 'a', label: '有挑战性(A)', hint: 'Ambitious,有雄心的、有挑战性的:跳一跳够得着' },
|
||||
{ key: 'r', label: '切实可行(R)', hint: 'Realistic,切实可行的:通过努力可以达到' },
|
||||
{ key: 't', label: '有时限(T)', hint: 'Time-related,有时间期限的' },
|
||||
];
|
||||
|
||||
export interface IpoocRow {
|
||||
|
||||
@@ -361,7 +361,21 @@ export function validateRaci(matrix: RaciMatrix): Issue[] {
|
||||
* `upstream` carries upstream payloads by code where a check compares against them
|
||||
* (S1-07 against S1-06); those checks only ever produce warnings.
|
||||
*/
|
||||
export const INCOMPLETE_ROW_MESSAGE = '表里有一条内容不完整(缺少必需的字段),请补全或删除这一条后再检查';
|
||||
|
||||
/**
|
||||
* Never throws: a draft can be partial (an AI suggestion being previewed, an old payload), and a
|
||||
* validator that trips over a missing row would take the whole page down.
|
||||
*/
|
||||
export function validateArtifact(code: string, payload: unknown, upstream: Record<string, unknown> = {}): Issue[] {
|
||||
try {
|
||||
return validateArtifactUnsafe(code, payload, upstream);
|
||||
} catch {
|
||||
return [{ level: 'error', message: INCOMPLETE_ROW_MESSAGE }];
|
||||
}
|
||||
}
|
||||
|
||||
function validateArtifactUnsafe(code: string, payload: unknown, upstream: Record<string, unknown> = {}): Issue[] {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
return [{ level: 'error', message: '成果内容为空' }];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Keeps one broken region (a suggestion card, an editor showing a preview) from taking the whole
|
||||
* workspace down. `resetKeys` re-tries the region when its input changes.
|
||||
*/
|
||||
export function RegionErrorBoundary({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
resetKeys,
|
||||
children,
|
||||
}: PropsWithChildren<{ title: string; description?: string; action?: { label: string; onClick: () => void }; resetKeys?: unknown[] }>) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
resetKeys={resetKeys}
|
||||
onError={(error) => console.error(`[dingze] ${title}`, error)}
|
||||
fallbackRender={({ resetErrorBoundary }): ReactNode => (
|
||||
<div role="alert" className="flex flex-col gap-2 rounded-lg border border-[color:var(--dz-warn)] bg-card p-3 text-sm">
|
||||
<span className="flex items-center gap-1.5 font-semibold text-[color:var(--dz-warn)]">
|
||||
<TriangleAlert className="size-4" /> {title}
|
||||
</span>
|
||||
{description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
|
||||
<div className="flex gap-2">
|
||||
{action ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" onClick={resetErrorBoundary}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -27,17 +27,36 @@ function clone<T>(value: T): T {
|
||||
return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T);
|
||||
}
|
||||
|
||||
/** An appended row without an id gets a stable one, so preview and confirmed write agree. */
|
||||
function withId(list: unknown[], value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (typeof row.id === "string" && row.id) return row;
|
||||
const taken = new Set(list.map((x) => (x && typeof x === "object" ? (x as { id?: unknown }).id : undefined)));
|
||||
let n = list.length + 1;
|
||||
while (taken.has(`ai${n}`)) n++;
|
||||
return { ...row, id: `ai${n}` };
|
||||
}
|
||||
|
||||
function setAt(target: unknown, segments: string[], value: unknown, fullPath: string): unknown {
|
||||
const [head, ...rest] = segments;
|
||||
if (Array.isArray(target)) {
|
||||
// A row list (objects with ids) only takes whole rows: the AI's output is not schema-checked,
|
||||
// and a half-streamed or partial value would otherwise break the editor and the validators.
|
||||
const rows = target.some((item) => item && typeof item === "object");
|
||||
if (head === "+") {
|
||||
if (rest.length) throw new ChangePathError(`“${fullPath}”:追加时不能再带下级路径`);
|
||||
return [...target, value];
|
||||
if (value === undefined || value === null || value === "") throw new ChangePathError(`“${fullPath}”:追加的内容是空的`);
|
||||
if (rows && (typeof value !== "object" || Array.isArray(value))) throw new ChangePathError(`“${fullPath}”:这里要追加一整条,而不是一个值`);
|
||||
return [...target, withId(target, value)];
|
||||
}
|
||||
const index = target.findIndex((item) => item && typeof item === "object" && (item as { id?: unknown }).id === head);
|
||||
if (index < 0) throw new ChangePathError(`“${fullPath}”:找不到 id 为 ${head} 的条目`);
|
||||
if (!rest.length && (value === null || typeof value !== "object" || Array.isArray(value))) {
|
||||
throw new ChangePathError(`“${fullPath}”:这里要换成一整条,而不是一个值`);
|
||||
}
|
||||
const next = [...target];
|
||||
next[index] = rest.length ? setAt(target[index], rest, value, fullPath) : value;
|
||||
next[index] = rest.length ? setAt(target[index], rest, value, fullPath) : { ...(value as object), id: head };
|
||||
return next;
|
||||
}
|
||||
if (target === null || typeof target !== "object") {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { type ArtifactStatus, STATUS_LABELS } from "@dingze/shared";
|
||||
|
||||
import type { CommentThread } from "./comments-api";
|
||||
|
||||
// What the digital consultant must know about the table's place in the flow, beyond its content
|
||||
// (验收 D04 / E D07): whether it was returned and why, open comments, which version is a draft and
|
||||
// which is confirmed. Structural validation passing says nothing about these.
|
||||
|
||||
export type WorkflowFacts = {
|
||||
status: ArtifactStatus;
|
||||
/** The last lifecycle event, from the project overview. */
|
||||
lastEvent?: { action: string; reason?: string | null; by?: string | null; at?: string | null } | null;
|
||||
stale?: boolean;
|
||||
staleReason?: string | null;
|
||||
currentRev?: number | null;
|
||||
stepDoneRev?: number | null;
|
||||
lockedRev?: number | null;
|
||||
comments?: CommentThread[];
|
||||
};
|
||||
|
||||
const clip = (s: string, n = 200) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
||||
|
||||
export function workflowContext(f: WorkflowFacts) {
|
||||
const returned = f.status === "in_progress" && !!f.lastEvent && ["returnToEdit", "forceReturn"].includes(f.lastEvent.action);
|
||||
const open = (f.comments ?? []).filter((c) => !c.resolved);
|
||||
const confirmation =
|
||||
f.status === "locked"
|
||||
? `已定版 v${f.lockedRev ?? "?"}:这是企业确认过的版本`
|
||||
: f.lockedRev
|
||||
? `当前是重开后的草稿 v${f.currentRev ?? "?"},尚未确认;上一次定版是 v${f.lockedRev}`
|
||||
: `尚未定版:表里的内容都是草稿(v${f.currentRev ?? 0}),不是企业已确认的事实`;
|
||||
return {
|
||||
statusLabel: STATUS_LABELS[f.status],
|
||||
returned: returned ? { reason: f.lastEvent?.reason || "(退回时没有写原因,请看批注)", by: f.lastEvent?.by ?? null, at: f.lastEvent?.at ?? null } : null,
|
||||
confirmation,
|
||||
versions: { current: f.currentRev ?? null, stepDone: f.stepDoneRev ?? null, locked: f.lockedRev ?? null },
|
||||
upstreamChanged: f.stale ? f.staleReason || "上游表有改动" : null,
|
||||
openComments: open.slice(0, 12).map((c) => ({
|
||||
where: c.anchorLabel || "整张表",
|
||||
author: c.author,
|
||||
content: clip(c.content),
|
||||
replies: c.replies.map((r) => `${r.author}:${clip(r.content, 120)}`),
|
||||
})),
|
||||
openCommentCount: open.length,
|
||||
rules: [
|
||||
"回答前先看 returned、openComments 和 confirmation:退回原因和批注就在这里,不要说“没有退回原因”。",
|
||||
"结构校验没有问题,不代表业务上已通过;退回、批注和待确认状态以这里为准。",
|
||||
"引用表里的数字时,说明它是草稿还是已定版、出自哪个字段;未定版的内容不能说成已确定的事实。",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,23 @@ export async function downloadPlanBookDocx(fileName: string, model: PlanBookMode
|
||||
}),
|
||||
];
|
||||
|
||||
// 版本与来源 (验收 D01 / D08): which version this file is and which table versions it was built from.
|
||||
if (model.provenance) {
|
||||
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 120 }, children: [new TextRun({ text: "版本与来源", color: BRAND })] }));
|
||||
for (const line of model.provenance.lines) children.push(new Paragraph({ spacing: { after: 60 }, children: [new TextRun({ text: line, size: 20 })] }));
|
||||
if (model.provenance.sources.length) {
|
||||
children.push(
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({ tableHeader: true, children: ["编号", "引用的成果", "版本"].map((h) => cell(h, true)) }),
|
||||
...model.provenance.sources.map((x) => new TableRow({ children: [cell(x.specId), cell(x.name), cell(x.version)] })),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chapter of model.chapters) {
|
||||
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, spacing: { before: 360, after: 120 }, children: [new TextRun({ text: `${chapter.no}、${chapter.title}`, color: BRAND })] }));
|
||||
for (const block of chapter.blocks) {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// `data-anchor`; an anchor such as `battlefields.b1.mustWin` or `sheet1.O2` falls back to its
|
||||
// shorter prefixes and then to its single segments (row ids), so the closest row is found.
|
||||
|
||||
export function anchorCandidates(anchor: string): string[] {
|
||||
export function anchorCandidates(anchor: string | null | undefined): string[] {
|
||||
// 整表批注、整表问题没有锚点。
|
||||
if (!anchor) return [];
|
||||
const parts = anchor.split(".").filter(Boolean);
|
||||
const out = [anchor];
|
||||
for (let i = parts.length - 1; i > 0; i--) out.push(parts.slice(0, i).join("."));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Provenance } from "./plan-book";
|
||||
import type { PlanBookSources } from "./records-api";
|
||||
|
||||
/** The 版本与来源 lines for a plan book export (验收 D01 / D08). */
|
||||
export function planBookProvenance(s: PlanBookSources, project: { enterprise: string; year: number; name: string }, now = new Date()): Provenance {
|
||||
const lines = [
|
||||
`项目:${project.enterprise} · ${project.name}(${project.year} 年度)`,
|
||||
s.locked ? `本文件:定版 v${s.rev},正文和各表都取自这一版定版时引用的版本` : `本文件:草稿 v${s.rev}(未定版,仅供讨论)`,
|
||||
`生成时间:${now.toLocaleString("zh-CN", { hour12: false })}`,
|
||||
];
|
||||
if (s.locked && !s.recorded) lines.push("来源版本按计划书定版的时间回溯(这一版定版早于来源记录功能上线)。");
|
||||
if (s.locked && s.stale) lines.push(`注意:定版之后上游已有改动(${s.staleReason || "上游表有新版本"})。本文件仍是原批准内容;新方案要重开计划书、重新定版后再导出新版本。`);
|
||||
return {
|
||||
lines,
|
||||
sources: s.sources.map((x) => ({ specId: x.specId, name: x.name, version: `v${x.rev} · ${x.basisLabel}` })),
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,10 @@ export type Block =
|
||||
|
||||
export type Chapter = { no: string; title: string; source: "same" | "entered"; blocks: Block[] };
|
||||
|
||||
export type PlanBookModel = { title: string; subtitle: string; chapters: Chapter[] };
|
||||
/** 版本与来源 page: which plan book version this is and which upstream versions it was built from. */
|
||||
export type Provenance = { lines: string[]; sources: { specId: string; name: string; version: string }[] };
|
||||
|
||||
export type PlanBookModel = { title: string; subtitle: string; chapters: Chapter[]; provenance?: Provenance };
|
||||
|
||||
export type PlanBookUpstream = {
|
||||
"S1-01"?: StrategyContent;
|
||||
|
||||
@@ -71,6 +71,24 @@ export async function getDeliveryBundle(projectId: number, spaceName: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export type PlanBookSources = {
|
||||
code: string;
|
||||
locked: boolean;
|
||||
rev: number;
|
||||
payload: unknown;
|
||||
/** false: locked before sources were recorded; reconstructed from the lock time. */
|
||||
recorded: boolean;
|
||||
stale: boolean;
|
||||
staleReason: string | null;
|
||||
sources: { code: string; specId: string; name: string; rev: number; basis: "locked" | "draft" | "at_time"; basisLabel: string }[];
|
||||
payloads: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** The plan book version to export and the exact upstream versions it was built from (验收 D01). */
|
||||
export async function getPlanBookSources(projectId: number, spaceName: string, code: string) {
|
||||
return nocobaseClient.action<PlanBookSources>("dingze", "planBookSources", { method: "GET", query: { projectId, code }, headers: spaceHeaders(spaceName) });
|
||||
}
|
||||
|
||||
export type ExportInfo = { code: string; rev: number; format: ExportFormat; fileName: string; draft: boolean };
|
||||
|
||||
export async function recordExport(projectId: number, spaceName: string, info: ExportInfo) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getArtifactRegistry,
|
||||
getDeliveryBundle,
|
||||
getMyNotifications,
|
||||
getPlanBookSources,
|
||||
getVersionDiff,
|
||||
getVersionPayload,
|
||||
markNotificationsRead,
|
||||
@@ -21,6 +22,15 @@ export const recordKeys = {
|
||||
notifications: ["dingze", "notifications"] as const,
|
||||
};
|
||||
|
||||
/** A plan book's recorded source versions; only fetched for a locked plan book. */
|
||||
export function usePlanBookSources(project: ProjectSummary, code: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["dingze", "plan-book-sources", project.id, code] as const,
|
||||
queryFn: () => getPlanBookSources(project.id, project.spaceName, code),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useArtifactRegistry(project: ProjectSummary) {
|
||||
return useQuery({ queryKey: recordKeys.registry(project.id), queryFn: () => getArtifactRegistry(project.id, project.spaceName) });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useAIChatMessages,
|
||||
useAIPageContextScope,
|
||||
} from "@/extensions/nocobase-ai/providers";
|
||||
import { type StageKey, diffPayloads } from "@dingze/shared";
|
||||
import { INCOMPLETE_ROW_MESSAGE, type StageKey, diffPayloads } from "@dingze/shared";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,6 +34,7 @@ import { useMaterials } from "@/lib/dingze/materials-api";
|
||||
import { type ChangeOp, OP_LABELS, type ProposedChange, applyChanges, describeValue } from "@/lib/dingze/changes";
|
||||
import { COACH_BY_STAGE, PROPOSE_TOOL } from "@/lib/dingze/coach";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RegionErrorBoundary } from "@/components/dingze/region-error-boundary";
|
||||
import { getAt, preview } from "@/lib/dingze/edit-log";
|
||||
|
||||
import { useProjectContext } from "../project-context";
|
||||
@@ -106,14 +107,24 @@ function SuggestionCard({
|
||||
// A card arriving within minutes of the user's message is live; replayed history does not preview.
|
||||
const live = useRef<boolean | null>(null);
|
||||
if (live.current === null) live.current = state === "input-streaming" || Date.now() - lastSendAt < 5 * 60_000;
|
||||
// 不采纳 takes effect on the click: the card closes and its preview goes away at once, without
|
||||
// waiting for the conversation to come back (验收 D02).
|
||||
const [rejected, setRejected] = useState(false);
|
||||
const decided =
|
||||
rejected ||
|
||||
state === "output-available" ||
|
||||
state === "output-error" ||
|
||||
state === "output-denied";
|
||||
const changes = args.changes ?? [];
|
||||
const reject: typeof onReject = (reason) => {
|
||||
setRejected(true);
|
||||
return onReject(reason);
|
||||
};
|
||||
|
||||
if (decided) {
|
||||
const { ok, message } = readOutcome(part);
|
||||
const outcome = readOutcome(part);
|
||||
const ok = !rejected && outcome.ok;
|
||||
const message = rejected && !outcome.message ? "表里没有任何改动。" : outcome.message;
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-3 text-sm">
|
||||
<div
|
||||
@@ -135,7 +146,16 @@ function SuggestionCard({
|
||||
);
|
||||
}
|
||||
|
||||
return <PendingCard args={args} live={live.current} disabled={disabled} onApprove={onApprove} onReject={onReject} onRevise={onRevise} />;
|
||||
return (
|
||||
<RegionErrorBoundary
|
||||
title="这条修改建议显示不了"
|
||||
description="建议的内容不完整,表里没有任何改动。可以不采纳,再让数字咨询师重出。"
|
||||
action={disabled ? undefined : { label: "不采纳", onClick: () => reject("建议内容不完整,用户不采纳") }}
|
||||
resetKeys={[JSON.stringify(args)]}
|
||||
>
|
||||
<PendingCard args={args} live={live.current} disabled={disabled} onApprove={onApprove} onReject={reject} onRevise={onRevise} />
|
||||
</RegionErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
type CardRow = { no: number | null; op: ChangeOp; name: string; before?: string; after?: string; parts?: { no: number; title: string; amount: string; note?: string }[] };
|
||||
@@ -210,12 +230,22 @@ function PendingCard({
|
||||
const built = useMemo(() => {
|
||||
if (!session) return null;
|
||||
try {
|
||||
return { ...cardRows(session, changes), invalid: null as string | null };
|
||||
const rows = cardRows(session, changes);
|
||||
// A suggestion with an incomplete row must not reach the preview, where editors would trip on it.
|
||||
if (session.validate(rows.next).some((i) => i.message === INCOMPLETE_ROW_MESSAGE)) throw new Error("建议里有一条内容不完整");
|
||||
return { ...rows, invalid: null as string | null };
|
||||
} catch (error) {
|
||||
return { rows: [] as CardRow[], items: [], next: session.draft, invalid: error instanceof Error ? error.message : "路径无效" };
|
||||
}
|
||||
}, [session, changes]);
|
||||
const [previewing, setPreviewing] = useState(live);
|
||||
// One decision per card: a fast double click must not send both 确认 and 不采纳.
|
||||
const [decided, setDecided] = useState(false);
|
||||
const decide = (fn: () => void) => () => {
|
||||
if (decided) return;
|
||||
setDecided(true);
|
||||
fn();
|
||||
};
|
||||
const key = JSON.stringify(built?.items ?? []);
|
||||
useEffect(() => {
|
||||
if (!session || !built || built.invalid) return;
|
||||
@@ -244,11 +274,15 @@ function PendingCard({
|
||||
const checks = useMemo(() => {
|
||||
const fn = session?.chrome.current?.checks;
|
||||
if (!fn || !built || built.invalid) return [];
|
||||
const was = new Map(fn(session!.draft).map((c) => [c.id, c]));
|
||||
const touched = new Set(built.items.map((i) => i.anchor.split(".").pop()));
|
||||
return fn(built.next)
|
||||
.filter((c) => touched.has(c.id) || was.get(c.id)?.text !== c.text)
|
||||
.map((c) => ({ ...c, same: was.get(c.id)?.text === c.text }));
|
||||
try {
|
||||
const was = new Map(fn(session!.draft).map((c) => [c.id, c]));
|
||||
const touched = new Set(built.items.map((i) => i.anchor.split(".").pop()));
|
||||
return fn(built.next)
|
||||
.filter((c) => touched.has(c.id) || was.get(c.id)?.text !== c.text)
|
||||
.map((c) => ({ ...c, same: was.get(c.id)?.text === c.text }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [session, built]);
|
||||
const comment = session?.activeComment ? session.commentInfo(session.activeComment) : null;
|
||||
|
||||
@@ -324,13 +358,13 @@ function PendingCard({
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" disabled={disabled || !!built?.invalid} onClick={() => onApprove()}>
|
||||
<Button size="sm" disabled={disabled || decided || !!built?.invalid} onClick={decide(() => onApprove())}>
|
||||
<Check /> 确认修改
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={disabled} onClick={() => onRevise()}>
|
||||
<Button size="sm" variant="outline" disabled={disabled || decided} onClick={decide(() => onRevise())}>
|
||||
<PencilLine /> 改一下
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={() => onReject("用户不采纳这条建议")}>
|
||||
<Button size="sm" variant="ghost" disabled={disabled || decided} onClick={decide(() => onReject("用户不采纳这条建议"))}>
|
||||
不采纳
|
||||
</Button>
|
||||
{built && !built.invalid && built.items.length ? (
|
||||
|
||||
@@ -22,7 +22,7 @@ export type Marks = {
|
||||
const esc = (a: string) => a.replace(/["\\\\]/g, "");
|
||||
|
||||
/** The element a path points at: the most specific `data-anchor` present on the page. */
|
||||
export function anchorElement(main: ParentNode, path: string): HTMLElement | null {
|
||||
export function anchorElement(main: ParentNode, path: string | null | undefined): HTMLElement | null {
|
||||
for (const c of anchorCandidates(path)) {
|
||||
const el = main.querySelector<HTMLElement>(`[data-anchor="${esc(c)}"]`);
|
||||
if (el) return el;
|
||||
|
||||
@@ -2,25 +2,29 @@ import { FileText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { type ArtifactStatus, type CompanyPlanBook, type DeptPlanBook, emptyPlanBookText } from "@dingze/shared";
|
||||
import { type CompanyPlanBook, type DeptPlanBook, emptyPlanBookText } from "@dingze/shared";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { errorMessage } from "@/lib/dingze/errors";
|
||||
import { downloadPlanBookDocx } from "@/lib/dingze/export/docx";
|
||||
import type { ExportInfo } from "@/lib/dingze/records-api";
|
||||
import type { ProjectSummary } from "@/lib/dingze/api";
|
||||
import { planBookProvenance } from "@/lib/dingze/plan-book-provenance";
|
||||
import { type ExportInfo, getPlanBookSources } from "@/lib/dingze/records-api";
|
||||
import { type PlanBookUpstream, buildCompanyPlanBook, buildDeptPlanBook, deptText } from "@/lib/dingze/plan-book";
|
||||
|
||||
const safe = (name: string) => name.replace(/[\\/:*?"<>|]/g, "_");
|
||||
|
||||
/** Plan books (S3-07 / S3-08) export to Word from the saved version; S3-08 writes one file per department. */
|
||||
/**
|
||||
* Plan books (S3-07 / S3-08) export to Word. The server picks the version: the locked one with the
|
||||
* exact upstream versions recorded when it was locked (验收 D01), otherwise the saved draft. S3-08
|
||||
* writes one file per department. Every file carries a 版本与来源 page.
|
||||
*/
|
||||
export function PlanBookExportButton({
|
||||
code,
|
||||
payload,
|
||||
project,
|
||||
rev,
|
||||
status,
|
||||
enterprise,
|
||||
year,
|
||||
upstream,
|
||||
dirty,
|
||||
onExported,
|
||||
variant = "outline",
|
||||
@@ -28,12 +32,11 @@ export function PlanBookExportButton({
|
||||
label,
|
||||
}: {
|
||||
code: string;
|
||||
payload: unknown;
|
||||
project: Pick<ProjectSummary, "id" | "name" | "spaceName">;
|
||||
/** Hides the button until there is a saved version. */
|
||||
rev: number;
|
||||
status: ArtifactStatus;
|
||||
enterprise: string;
|
||||
year: number;
|
||||
upstream: Record<string, unknown>;
|
||||
dirty: boolean;
|
||||
onExported?: (info: ExportInfo) => void;
|
||||
variant?: "default" | "outline" | "ghost";
|
||||
@@ -41,20 +44,22 @@ export function PlanBookExportButton({
|
||||
label?: string;
|
||||
}) {
|
||||
const [pending, setPending] = useState(false);
|
||||
if ((code !== "S3-07" && code !== "S3-08") || rev === 0 || !payload) return null;
|
||||
const u = upstream as PlanBookUpstream;
|
||||
const draft = status !== "locked";
|
||||
if ((code !== "S3-07" && code !== "S3-08") || rev === 0) return null;
|
||||
|
||||
const run = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
const s = await getPlanBookSources(project.id, project.spaceName, code);
|
||||
const u = s.payloads as PlanBookUpstream;
|
||||
const draft = !s.locked;
|
||||
const provenance = planBookProvenance(s, { enterprise, year, name: project.name });
|
||||
if (code === "S3-07") {
|
||||
const text = (payload as CompanyPlanBook).text ?? emptyPlanBookText();
|
||||
const fileName = safe(`${enterprise}-${year}年度经营计划书-v${rev}.docx`);
|
||||
await downloadPlanBookDocx(fileName, buildCompanyPlanBook({ enterprise, year, text, upstream: u }), draft);
|
||||
onExported?.({ code, rev, format: "docx", fileName, draft });
|
||||
const text = (s.payload as CompanyPlanBook | null)?.text ?? emptyPlanBookText();
|
||||
const fileName = safe(`${enterprise}-${year}年度经营计划书-${draft ? "草稿" : "定版"}v${s.rev}.docx`);
|
||||
await downloadPlanBookDocx(fileName, { ...buildCompanyPlanBook({ enterprise, year, text, upstream: u }), provenance }, draft);
|
||||
onExported?.({ code, rev: s.rev, format: "docx", fileName, draft });
|
||||
} else {
|
||||
const depts = (payload as DeptPlanBook).depts ?? [];
|
||||
const depts = (s.payload as DeptPlanBook | null)?.depts ?? [];
|
||||
if (depts.length === 0) {
|
||||
toast.info("还没有部门计划书可导出");
|
||||
return;
|
||||
@@ -62,12 +67,13 @@ export function PlanBookExportButton({
|
||||
for (const d of depts) {
|
||||
const { deptId, deptName } = d;
|
||||
const text = deptText(d);
|
||||
const fileName = safe(`${enterprise}-${deptName}-${year}年度经营计划书-v${rev}.docx`);
|
||||
await downloadPlanBookDocx(fileName, buildDeptPlanBook({ enterprise, year, dept: { deptId, deptName }, text, upstream: u }), draft);
|
||||
onExported?.({ code, rev, format: "docx", fileName, draft });
|
||||
const fileName = safe(`${enterprise}-${deptName}-${year}年度经营计划书-${draft ? "草稿" : "定版"}v${s.rev}.docx`);
|
||||
await downloadPlanBookDocx(fileName, { ...buildDeptPlanBook({ enterprise, year, dept: { deptId, deptName }, text, upstream: u }), provenance }, draft);
|
||||
onExported?.({ code, rev: s.rev, format: "docx", fileName, draft });
|
||||
}
|
||||
}
|
||||
if (dirty) toast.info("已导出最近一次保存的版本,未保存的修改不在文件里");
|
||||
if (s.locked && s.stale) toast.info(`导出的是定版 v${s.rev} 批准时的内容;之后上游的改动不在文件里`);
|
||||
else if (dirty) toast.info("已导出最近一次保存的版本,未保存的修改不在文件里");
|
||||
} catch (error) {
|
||||
toast.error(`导出失败:${errorMessage(error)}`);
|
||||
} finally {
|
||||
|
||||
@@ -118,12 +118,10 @@ export default function DeliveryPage({ embedded = false }: { embedded?: boolean
|
||||
{a?.payload ? (
|
||||
<PlanBookExportButton
|
||||
code={code}
|
||||
payload={a.payload}
|
||||
project={project}
|
||||
rev={a.rev}
|
||||
status={a.locked ? "locked" : a.status}
|
||||
enterprise={project.enterprise?.name ?? enterprise}
|
||||
year={project.year}
|
||||
upstream={upstream}
|
||||
dirty={false}
|
||||
onExported={recordExport}
|
||||
variant={code === "S3-07" ? "default" : "outline"}
|
||||
|
||||
@@ -134,9 +134,9 @@ export function IpoocEditor({ value, onChange, readOnly, issues, upstream }: Pro
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<legend className="mb-1 text-xs font-semibold text-muted-foreground">SMART 验证</legend>
|
||||
<legend className="mb-1 text-xs font-semibold text-muted-foreground">SMART 验证(按书稿:A 有挑战性、R 切实可行)</legend>
|
||||
{SMART_CHECKS.map((check) => (
|
||||
<label key={check.key} className="flex items-center gap-2">
|
||||
<label key={check.key} className="flex items-center gap-2" title={check.hint}>
|
||||
<Checkbox
|
||||
checked={!!sheet.smart?.[check.key]}
|
||||
disabled={readOnly}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
defineAIFrontendTool,
|
||||
} from "@/extensions/nocobase-ai/providers";
|
||||
|
||||
import { RegionErrorBoundary } from "@/components/dingze/region-error-boundary";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -35,7 +36,7 @@ import { useAddComment } from "@/lib/dingze/comments-api";
|
||||
import { type Command, type CommandMeta, appendCommand, buildCommand, preview, reapplyCommand, revertCommand, toSaved } from "@/lib/dingze/edit-log";
|
||||
import { getVersionPayload } from "@/lib/dingze/records-api";
|
||||
import { nextArtifact } from "@/lib/dingze/next-step";
|
||||
import { useArtifactHistory, useRecordExport } from "@/lib/dingze/records-queries";
|
||||
import { useArtifactHistory, usePlanBookSources, useRecordExport } from "@/lib/dingze/records-queries";
|
||||
import { formatTime } from "@/lib/dingze/records";
|
||||
import { locateAnchor } from "@/lib/dingze/locate";
|
||||
import { buildRoute, topbarItems } from "@/lib/dingze/route";
|
||||
@@ -43,6 +44,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { EDITABLE_CODES, emptyPayload } from "@/lib/dingze/artifact-payloads";
|
||||
import { type ProposedChange, applyChanges } from "@/lib/dingze/changes";
|
||||
import { workflowContext } from "@/lib/dingze/coach-context";
|
||||
import { PROPOSE_TOOL } from "@/lib/dingze/coach";
|
||||
import { errorMessage } from "@/lib/dingze/errors";
|
||||
|
||||
@@ -108,9 +110,13 @@ function Workspace({ code }: { code: string }) {
|
||||
const def = getArtifactDef(code);
|
||||
const detail = useArtifactDetail(project, code);
|
||||
const save = useSaveArtifact(project, code);
|
||||
const upstream = useUpstreamPayloads(project, [...def.dependsOn, ...(def.reads ?? [])]).payloads;
|
||||
const liveUpstream = useUpstreamPayloads(project, [...def.dependsOn, ...(def.reads ?? [])]).payloads;
|
||||
|
||||
const artifact = detail.data?.artifact ?? null;
|
||||
// A locked plan book shows the tables as they were when it was locked, same as its Word file (验收 D01).
|
||||
const lockedPlanBook = (code === "S3-07" || code === "S3-08") && artifact?.status === "locked";
|
||||
const planSources = usePlanBookSources(project, code, lockedPlanBook);
|
||||
const upstream = lockedPlanBook && planSources.data ? planSources.data.payloads : liveUpstream;
|
||||
const serverPayload =
|
||||
artifact?.currentVersion?.payload ?? emptyPayload(code, project);
|
||||
const serverRev = artifact?.currentRev ?? 0;
|
||||
@@ -618,6 +624,16 @@ function Workspace({ code }: { code: string }) {
|
||||
issues: latest.current.draft
|
||||
? validateArtifact(code, latest.current.draft, upstream)
|
||||
: [],
|
||||
workflow: workflowContext({
|
||||
status,
|
||||
lastEvent: progress?.lastEvent,
|
||||
stale: artifact?.stale,
|
||||
staleReason: artifact?.staleReason,
|
||||
currentRev: latest.current.baseRev,
|
||||
stepDoneRev: artifact?.stepDoneVersion?.rev ?? null,
|
||||
lockedRev: artifact?.lockedVersion?.rev ?? null,
|
||||
comments: comments.data,
|
||||
}),
|
||||
enterpriseProfile: profileBrief(profile.data) || "(企业画像还没有内容)",
|
||||
materialsHint: `起草或核对数字时可用 ${SEARCH_MATERIALS_TOOL} 查阅企业资料`,
|
||||
}),
|
||||
@@ -786,12 +802,10 @@ function Workspace({ code }: { code: string }) {
|
||||
/>
|
||||
<PlanBookExportButton
|
||||
code={code}
|
||||
payload={artifact?.currentVersion?.payload}
|
||||
project={project}
|
||||
rev={artifact?.currentRev ?? 0}
|
||||
status={status}
|
||||
enterprise={project.enterprise?.name ?? "企业"}
|
||||
year={project.year}
|
||||
upstream={upstream}
|
||||
dirty={dirty}
|
||||
onExported={recordExport}
|
||||
variant="ghost"
|
||||
@@ -1001,6 +1015,12 @@ function Workspace({ code }: { code: string }) {
|
||||
) : null}
|
||||
|
||||
{EDITABLE_CODES.has(code) ? (
|
||||
<RegionErrorBoundary
|
||||
title={previewDraft ? "这条修改建议预览不了" : "这张表显示出错"}
|
||||
description={previewDraft ? "建议的内容不完整,表里没有任何改动。收起预览后可以在修改卡上不采纳。" : "可以刷新页面重试;未保存的修改在“修改”里。"}
|
||||
action={previewDraft ? { label: "收起预览", onClick: () => setPreviewDraft(null) } : undefined}
|
||||
resetKeys={[previewDraft, code]}
|
||||
>
|
||||
<ArtifactEditor
|
||||
code={code}
|
||||
value={previewDraft ? previewDraft.payload : draft}
|
||||
@@ -1015,6 +1035,7 @@ function Workspace({ code }: { code: string }) {
|
||||
scheduleScale={project.scheduleScale}
|
||||
enterprise={project.enterprise?.name ?? "企业"}
|
||||
/>
|
||||
</RegionErrorBoundary>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>这张表暂无在线编辑器</AlertTitle>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ChangePathError, applyChanges } from "@/lib/dingze/changes";
|
||||
import { anchorCandidates } from "@/lib/dingze/locate";
|
||||
|
||||
// 验收 E D01:AI 给的内容没有经过格式检查,残缺的一条不能进表;D03:整表批注没有锚点。
|
||||
describe("applyChanges guards", () => {
|
||||
const draft = { charters: [{ id: "c1", name: "加气站" }] };
|
||||
|
||||
it("rejects an empty or scalar append into a row list", () => {
|
||||
expect(() => applyChanges(draft, [{ path: "charters.+", value: undefined }])).toThrow(ChangePathError);
|
||||
expect(() => applyChanges(draft, [{ path: "charters.+", value: "加气站二期" }])).toThrow(ChangePathError);
|
||||
});
|
||||
|
||||
it("gives an appended row without an id a stable one", () => {
|
||||
const next = applyChanges(draft, [{ path: "charters.+", value: { name: "二期" } }]);
|
||||
expect(next.charters[1]).toEqual({ name: "二期", id: "ai2" });
|
||||
});
|
||||
|
||||
it("keeps the row id when a whole row is replaced, and refuses a scalar", () => {
|
||||
const next = applyChanges(draft, [{ path: "charters.c1", value: { name: "改名" } }]);
|
||||
expect(next.charters[0]).toEqual({ name: "改名", id: "c1" });
|
||||
expect(() => applyChanges(draft, [{ path: "charters.c1", value: null }])).toThrow(ChangePathError);
|
||||
});
|
||||
});
|
||||
|
||||
it("anchorCandidates has nothing for a whole-table anchor", () => {
|
||||
expect(anchorCandidates(undefined)).toEqual([]);
|
||||
expect(anchorCandidates(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("validateArtifact reports an incomplete row instead of throwing", async () => {
|
||||
const { INCOMPLETE_ROW_MESSAGE, validateArtifact } = await import("@dingze/shared");
|
||||
const issues = validateArtifact("S3-02", { screening: [{ id: "s1" }], charters: [undefined] });
|
||||
expect(issues.map((i) => i.message)).toContain(INCOMPLETE_ROW_MESSAGE);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CommentThread } from "@/lib/dingze/comments-api";
|
||||
import { workflowContext } from "@/lib/dingze/coach-context";
|
||||
|
||||
const thread = (id: number, content: string, resolved = false): CommentThread => ({
|
||||
id,
|
||||
parentId: null,
|
||||
anchor: "strategicGoals",
|
||||
anchorLabel: "战略目标",
|
||||
content,
|
||||
mentions: [],
|
||||
rev: 1,
|
||||
resolved,
|
||||
resolvedAt: null,
|
||||
authorId: 1,
|
||||
author: "陈顾问",
|
||||
at: "2026-10-13T00:00:00Z",
|
||||
replies: [],
|
||||
});
|
||||
|
||||
describe("workflowContext", () => {
|
||||
it("carries the return reason and open comments of a returned draft", () => {
|
||||
const c = workflowContext({
|
||||
status: "in_progress",
|
||||
lastEvent: { action: "returnToEdit", reason: "战略目标量化到 2030 年" },
|
||||
currentRev: 1,
|
||||
comments: [thread(1, "口径对齐十五五"), thread(2, "已改", true)],
|
||||
});
|
||||
expect(c.returned?.reason).toBe("战略目标量化到 2030 年");
|
||||
expect(c.confirmation).toContain("尚未定版");
|
||||
expect(c.openComments.map((x) => x.content)).toEqual(["口径对齐十五五"]);
|
||||
});
|
||||
|
||||
it("tells a reopened draft from the confirmed version", () => {
|
||||
const c = workflowContext({ status: "in_progress", currentRev: 4, lockedRev: 3, stale: true, staleReason: "S2-03 已改" });
|
||||
expect(c.returned).toBeNull();
|
||||
expect(c.confirmation).toBe("当前是重开后的草稿 v4,尚未确认;上一次定版是 v3");
|
||||
expect(c.upstreamChanged).toBe("S2-03 已改");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { planBookProvenance } from "@/lib/dingze/plan-book-provenance";
|
||||
import type { PlanBookSources } from "@/lib/dingze/records-api";
|
||||
|
||||
const base: PlanBookSources = {
|
||||
code: "S3-07",
|
||||
locked: true,
|
||||
rev: 2,
|
||||
payload: {},
|
||||
recorded: true,
|
||||
stale: false,
|
||||
staleReason: null,
|
||||
sources: [{ code: "S3-02", specId: "S3-02", name: "项目任务书", rev: 3, basis: "locked", basisLabel: "定版" }],
|
||||
payloads: {},
|
||||
};
|
||||
const project = { enterprise: "瀚川燃气", year: 2026, name: "2026 年度战略落地" };
|
||||
|
||||
it("names the locked plan book version and each source version", () => {
|
||||
const p = planBookProvenance(base, project, new Date("2026-10-13T08:00:00Z"));
|
||||
expect(p.lines[1]).toContain("定版 v2");
|
||||
expect(p.sources).toEqual([{ specId: "S3-02", name: "项目任务书", version: "v3 · 定版" }]);
|
||||
});
|
||||
|
||||
it("warns when upstream changed after the lock and when sources were reconstructed", () => {
|
||||
const p = planBookProvenance({ ...base, recorded: false, stale: true, staleReason: "S3-02 已解锁重开" }, project);
|
||||
expect(p.lines.join("\n")).toContain("按计划书定版的时间回溯");
|
||||
expect(p.lines.join("\n")).toContain("仍是原批准内容");
|
||||
});
|
||||
Reference in New Issue
Block a user