Add artifact registry, delivery page, notifications and isolation checks

- 成果与定版: every artifact's status, versions, last operator, AI-draft-to-locked
  difference, per-artifact history and two-version comparison, list export
- 交付: locked deliverables (drafts watermarked), per-department scorecards, export log
- In-app notifications for review, confirmation, return, lock, reopen, stale upstream
  and project assignment, with an optional dingze-email channel; bell in both headers
- Stale marks clear when a reopened upstream is locked again unchanged
- Cross-enterprise isolation E2E, Chinese-aware login E2E, user guide; plugin 0.8.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MMDFmVTvRksi1ZT8aPGXoL
This commit is contained in:
Claude
2026-10-09 17:54:50 +00:00
parent f0aab5963e
commit 4f01bbb923
32 changed files with 1729 additions and 18 deletions
+8
View File
@@ -11,3 +11,11 @@ NOCOBASE_E2E_ACCOUNT=nocobase
NOCOBASE_E2E_PASSWORD=
NOCOBASE_E2E_AUTHENTICATOR=basic
# NOCOBASE_E2E_ROLE=admin
# 定责跨企业隔离用例(e2e/dingze-isolation.spec.ts):一个项目成员、一个其他企业的账号。
# DINGZE_E2E_PROJECT_ID=
# DINGZE_E2E_SPACE=
# DINGZE_E2E_MEMBER_ACCOUNT=
# DINGZE_E2E_MEMBER_PASSWORD=
# DINGZE_E2E_OUTSIDER_ACCOUNT=
# DINGZE_E2E_OUTSIDER_PASSWORD=
+12
View File
@@ -49,3 +49,15 @@ done
1. 用咨询师账号登录 `https://<host>/x/dingze/`,应进入“咨询师工作台”。
2. 用企业项目负责人账号登录,应进入“我的项目”,并能打开工作区与数字咨询师。
3. 用运营管理员账号登录,侧栏应有“运营管理”。
## 5. 邮件通知(可选)
站内通知随插件开箱可用。要同时发邮件,在 NocoBase 的“通知管理”里新建一个**邮件**类型的通道,名称必须是 `dingze-email`,填好 SMTP。插件发现这个通道后,每条站内通知会同时发到收件人账号上登记的邮箱,正文末尾附门户直达链接;没有这个通道时只发站内信,不报错。
## 6. 跨企业隔离 E2E
`e2e/dingze-isolation.spec.ts` 用一个项目成员和一个其他企业的账号,验证成果、历史、版本对比、交付数据、导出记录、通知和底层数据表都不能跨企业访问。变量见 `.env.e2e.example` 里的 `DINGZE_E2E_*`,复制到 `.env.e2e` 后执行:
```bash
pnpm exec playwright test e2e/dingze-isolation.spec.ts
```
+99
View File
@@ -0,0 +1,99 @@
# 使用说明
自驱战略 · 定三责平台把《自驱战略》书中的三张检查表变成一步一步的在线工作:**定战略责 → 定目标责 → 定行动责**。每一步都有一张成果表,企业填写、咨询师复核、企业确认后定版;下游表格引用上游的定版内容,上游一改,下游会提示“上游已变更”。
入口:`https://<域名>/x/dingze/`,用分配的账号登录(平台不开放自助注册)。
---
## 一、企业成员
### 1. 进入项目
登录后进入“我的项目”,点开项目进入**工作台**:三阶段进度、待办和“上游已变更”的成果都在这里。
项目顶部有四个页签:
| 页签 | 用途 |
|---|---|
| 工作台 | 项目概览、待办 |
| 咨询工作区 | 逐表填写成果(主要工作区域) |
| 成果与定版 | 全部成果的状态、版本、操作记录和版本差异 |
| 交付 | 导出正式交付物、各部门绩效计分卡、导出记录 |
### 2. 在咨询工作区填写成果
- 左侧是步骤条,按书的顺序排列。前一步“本步完成”后,后一步才解锁;上一阶段 P0 成果全部定版后,下一阶段才开启。
- 中间是成果表。很多表有“带入 / 导入”按钮,可以从上游表直接带入,不用重抄。
- 下方的校验条会列出必须改的问题(红色)和建议(黄色)。红色问题没改完,不能点“本步完成”。
- 右侧是**数字咨询师**:可以就当前这张表提问,让它出修改建议。建议以卡片形式给出,**点“确认写入”才会改动成果**。
- 改完记得点“保存”。每次保存都会生成一个新版本。
### 3. 本步完成 → 复核 → 确认
1. 企业填写完毕,由企业项目负责人或部门负责人点 **本步完成**。
2. 咨询师、企业项目负责人或部门负责人**提交复核**,主咨询师**复核通过**或**退回修改**(退回必须写原因)。
3. 复核通过后,**企业项目负责人**点 **确认**,成果**定版**。定版后只读,如需修改,由主咨询师“解锁重开”。
> 只有企业项目负责人可以确认定版;企业管理员身份本身不带确认权。
不同意但愿意执行的意见,可以在成果下方“团队异议(反对但承诺执行)”里记录。
### 4. 年度经营计划书
定行动责的最后两张是公司级和部门级年度经营计划书。“企业录入”的章节由企业填写;“同源 · 自动汇总”的章节直接引用已定版的成果,不需要也不能重写。
公司级计划书确认时,它引用的《项目任务书》《计划实施推进表》《项目资源匹配表》必须已复核通过,确认后四张表**一起定版**。
### 5. 导出
- 成果页右上角“导出 Excel / 导出 Word”导出最近一次**保存**的版本。
- “交付”页导出的是**定版版本**;尚未定版的导出文件会标“草稿”。
- 所有导出都记录在“交付 → 导出记录”。
### 6. 通知
右上角铃铛显示站内通知:待复核、待确认、退回、定版、重开、上游已变更、加入项目。点通知直达对应成果。
---
## 二、咨询师
- 登录后进入**咨询师工作台**:跨企业汇总待复核、待提交复核、待企业确认、上游已变更和待跟进的成果,可按企业和阶段筛选。
- 复核:打开成果 → 查看校验结果和“成果与定版 → 历史 → 版本对比” → 复核通过或退回(写原因)。
- 解锁重开:已定版成果需要修改时,由主咨询师“解锁重开”并写明原因;下游表格会标“上游已变更”,企业核对后保存一次即可消除。重开后未改动又定版,下游提示会自动消失。
- 开例外:确有需要跳过解锁顺序时,主咨询师可为某一步“开例外”,操作会写入审计记录。
- **成果与定版**页的“AI 初稿 → 定版”一列,显示数字咨询师给出的最后一版初稿与最终定版之间的差异,用于方法和规则改进。
---
## 三、运营管理员 / 咨询管理员
侧栏“运营管理”:
| 页面 | 用途 |
|---|---|
| 企业与开通 | 开通企业(自动建空间和企业管理员账号)、停用 / 启用企业、维护企业成员和组织部门、新建项目、分派项目成员与项目角色 |
| 咨询团队 | 创建咨询师、咨询管理员、运营管理员账号,重置密码 |
| 方法与规则包 | 成果目录与数字咨询师(只读) |
要点:
- 一个企业对应一个独立空间,企业之间的项目、成果、通知、导出互不可见。
- 停用企业后,该企业成员立即无法打开项目(运营与咨询管理员仍可查看)。
- 新账号的初始密码只在创建时显示一次,请通过安全渠道转交,并提醒首次登录后修改。
- 企业管理员可在“本企业”里维护本企业成员和组织部门。
---
## 四、角色速查
| 项目角色 | 能做什么 |
|---|---|
| 企业项目负责人 | 填写、本步完成、提交复核、确认定版、待确认时退回 |
| 部门负责人 | 填写、本步完成、提交复核 |
| 项目成员 | 填写 |
| 只读成员 | 查看 |
| 主咨询师 | 修订、提交复核、复核通过 / 退回、解锁重开、开例外 |
| 协作咨询师 | 修订、提交复核 |
| 咨询管理员(系统角色) | 解锁重开、开例外、强制定版、强制退回(均需写原因) |
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from "@playwright/test";
import { PortalE2EApiError, type PortalE2ESession, loadPortalE2EEnvironment, portalAction, signInPortal } from "./support";
// Cross-enterprise isolation (spec 5.6 §4): an account of another enterprise must not read
// or write a project's artifacts, history, delivery data, exports or notifications.
// Requires DINGZE_E2E_* variables (see .env.e2e.example); skipped when they are absent.
const environment = loadPortalE2EEnvironment();
const env = (name: string) => process.env[name]?.trim() || undefined;
const projectId = Number(env("DINGZE_E2E_PROJECT_ID"));
const space = env("DINGZE_E2E_SPACE");
const member = { account: env("DINGZE_E2E_MEMBER_ACCOUNT"), password: env("DINGZE_E2E_MEMBER_PASSWORD") };
const outsider = { account: env("DINGZE_E2E_OUTSIDER_ACCOUNT"), password: env("DINGZE_E2E_OUTSIDER_PASSWORD") };
const configured = !!projectId && !!space && !!member.account && !!member.password && !!outsider.account && !!outsider.password;
test.describe("dingze cross-enterprise isolation", () => {
test.skip(!configured, "DINGZE_E2E_* variables are not set");
const signIn = (request: Parameters<typeof signInPortal>[0], who: { account?: string; password?: string }) =>
signInPortal(request, environment, { account: who.account!, password: who.password!, authenticator: environment.authenticator });
const status = async (run: () => Promise<unknown>) => {
try {
await run();
return 200;
} catch (error) {
if (error instanceof PortalE2EApiError) return error.status;
throw error;
}
};
test("a project member reads the registry and delivery data", async ({ request }) => {
const session = await signIn(request, member);
const call = (action: string, query: Record<string, string | number> = {}) =>
portalAction<unknown[]>(request, environment, "dingze", action, { method: "GET", session, query: { projectId, ...query }, headers: { "x-spaces": space! } });
const registry = await call("artifactRegistry");
expect(Array.isArray(registry) && registry.length).toBeGreaterThan(0);
expect(await status(() => call("deliveryBundle"))).toBe(200);
});
test("another enterprise's account is refused everywhere", async ({ request }) => {
const session: PortalE2ESession = await signIn(request, outsider);
const own = await portalAction<{ spaces: { name: string }[] }>(request, environment, "spaces", "my", { method: "GET", session });
const ownSpace = own.spaces[0]?.name;
// With the target project's space header the multi-space layer refuses; with the
// outsider's own space the plugin's project check does.
for (const headers of [{ "x-spaces": space! }, ...(ownSpace ? [{ "x-spaces": ownSpace }] : [])]) {
for (const [action, query] of [
["artifactRegistry", {}],
["artifactHistory", { code: "S1-01" }],
["versionDiff", { code: "S1-01", from: 1, to: 2 }],
["deliveryBundle", {}],
["artifactDetail", { code: "S1-01" }],
["projectOverview", {}],
] as const) {
const code = await status(() => portalAction(request, environment, "dingze", action, { method: "GET", session, headers, query: { projectId, ...query } }));
expect([403, 404], `${action} with ${headers["x-spaces"]}`).toContain(code);
}
const write = await status(() =>
portalAction(request, environment, "dingze", "recordExport", { session, headers, body: { projectId, code: "S1-01", rev: 1, format: "xlsx", fileName: "x" } })
);
expect([403, 404]).toContain(write);
}
const inbox = await portalAction<{ items: { projectId: number }[] }>(request, environment, "dingze", "myNotifications", { method: "GET", session });
expect(inbox.items.filter((n) => n.projectId === projectId)).toEqual([]);
for (const collection of ["dz_artifacts", "dz_artifact_versions", "dz_notifications", "dz_exports"]) {
const code = await status(() => portalAction(request, environment, collection, "list", { method: "GET", session, headers: { "x-spaces": space! } }));
expect([403, 404], collection).toContain(code);
}
});
});
+6 -6
View File
@@ -15,11 +15,11 @@ test("signs in and preserves the session after reload", async ({ page }) => {
await page.goto(resolvePortalTestURL(environment, "/login"));
await page
.getByLabel("Username or email", { exact: true })
.fill(credentials.account);
await page.getByLabel("Password", { exact: true }).fill(credentials.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
// The Portal signs in in Chinese; English labels are kept for other locales.
const accountField = page.getByLabel(/^(用户名|账号|用户名或邮箱|Username or email)$/).first();
await accountField.fill(credentials.account);
await page.getByLabel(/^(密码|Password)$/).first().fill(credentials.password);
await page.getByRole("button", { name: /^(登录|Sign in)$/ }).first().click();
await expect
.poll(() => new URL(page.url()).pathname)
@@ -29,6 +29,6 @@ test("signs in and preserves the session after reload", async ({ page }) => {
await expect
.poll(() => new URL(page.url()).pathname)
.not.toMatch(/\/(?:login|signin)\/?$/);
await expect(page.getByLabel("Username or email")).toHaveCount(0);
await expect(page.getByLabel(/^(用户名|账号|用户名或邮箱|Username or email)$/)).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
+1 -1
View File
@@ -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.7.0",
"version": "0.8.2",
"main": "dist/server/index.js",
"dependencies": {},
"peerDependencies": {
@@ -0,0 +1,21 @@
import { defineCollection } from '@nocobase/database';
import { spaceField } from './space-field';
export default defineCollection({
name: 'dz_exports',
title: '导出记录',
createdBy: true,
updatedBy: true,
fields: [
{ type: 'belongsTo', name: 'project', target: 'dz_projects', foreignKey: 'projectId' },
{ type: 'string', name: 'code', allowNull: false },
{ type: 'integer', name: 'rev' },
// xlsx | docx
{ type: 'string', name: 'format', allowNull: false },
{ type: 'string', name: 'fileName' },
// Exported from a version that was not yet locked (watermarked draft).
{ type: 'boolean', name: 'draft', defaultValue: false },
spaceField,
],
});
@@ -0,0 +1,23 @@
import { defineCollection } from '@nocobase/database';
import { spaceField } from './space-field';
export default defineCollection({
name: 'dz_notifications',
title: '站内通知',
createdBy: true,
updatedBy: true,
fields: [
{ type: 'belongsTo', name: 'user', target: 'users', foreignKey: 'userId' },
{ type: 'belongsTo', name: 'project', target: 'dz_projects', foreignKey: 'projectId' },
{ type: 'string', name: 'code' },
// step_done | review | confirm | returned | locked | reopened | stale | assigned
{ type: 'string', name: 'kind', allowNull: false },
{ type: 'string', name: 'title', allowNull: false },
{ type: 'text', name: 'content' },
// Portal path, e.g. /projects/1/workspace/S1-02
{ type: 'string', name: 'link' },
{ type: 'date', name: 'readAt' },
spaceField,
],
});
@@ -14,6 +14,8 @@ import {
transitionArtifact,
} from './services/artifacts';
import { consultantBoard } from './services/board';
import { markNotificationsRead, myNotifications } from './services/notify';
import { artifactHistory, artifactRegistry, deliveryBundle, recordExport, versionDiff } from './services/registry';
import { createProject, listEnterprises, myProjects, provisionEnterprise, setProjectMembers } from './services/enterprises';
import {
addEnterpriseMembers,
@@ -104,6 +106,24 @@ export class PluginDingzeServer extends Plugin {
const pc = await loadProjectContext(ctx, v.projectId);
return transitionArtifact(ctx, pc, requireArtifactDef(v.code), v.action, v.reason);
}),
artifactRegistry: action(async (ctx) => artifactRegistry(ctx, await loadProjectContext(ctx, ctx.action.params.projectId))),
artifactHistory: action(async (ctx) => {
const pc = await loadProjectContext(ctx, ctx.action.params.projectId);
return artifactHistory(ctx, pc, requireArtifactDef(ctx.action.params.code));
}),
versionDiff: action(async (ctx) => {
const p = ctx.action.params;
const pc = await loadProjectContext(ctx, p.projectId);
return versionDiff(ctx, pc, requireArtifactDef(p.code), p.from, p.to);
}),
deliveryBundle: action(async (ctx) => deliveryBundle(ctx, await loadProjectContext(ctx, ctx.action.params.projectId))),
recordExport: action(async (ctx) => {
const v = values(ctx);
const pc = await loadProjectContext(ctx, v.projectId);
return recordExport(ctx, pc, requireArtifactDef(v.code), v);
}),
myNotifications: action((ctx) => myNotifications(ctx, ctx.action.params as any)),
markNotificationsRead: action((ctx) => markNotificationsRead(ctx, values(ctx))),
recordDissent: action(async (ctx) => {
const v = values(ctx);
const pc = await loadProjectContext(ctx, v.projectId);
@@ -19,6 +19,7 @@ import {
validateArtifact,
} from '../../shared';
import { HttpError, type ProjectContext, currentUserId } from './access';
import { notifyTransition } from './notify';
const LIFECYCLE_HTTP: Record<LifecycleError['code'], number> = {
FORBIDDEN: 403,
@@ -230,11 +231,25 @@ async function markDownstreamStale(
if (target && (unconditional || refs[def.code] !== latest)) {
await target.update({ stale: true, staleReason: reason }, { transaction });
marked.push(code);
} else if (target?.get('stale') && (await readsCurrentUpstream(ctx, projectId, code, refs, transaction))) {
// An upstream reopened and re-locked without changes: nothing to re-check any more.
await target.update({ stale: false, staleReason: null }, { transaction });
}
}
return marked;
}
/** Whether every upstream `code` read is still the version it would read now, and none is being edited. */
async function readsCurrentUpstream(ctx: Context, projectId: number, code: string, refs: Record<string, number | null>, transaction: any) {
const def = getArtifactDef(code);
const rows = await ctx.db.getRepository('dz_artifacts').find({ filter: { projectId, code: { $in: def.dependsOn } }, transaction });
return def.dependsOn.every((dep) => {
const row = rows.find((r: any) => r.get('code') === dep);
if (!row) return refs[dep] == null;
return !isReopened(row) && readableVersionId(row, getArtifactDef(dep).stage === def.stage) === (refs[dep] ?? null);
});
}
const PLAN_BOOKS = ['S3-07', 'S3-08'];
/** Current content of the artifacts `def` depends on, for checks that compare against them. */
@@ -302,8 +317,10 @@ export async function transitionArtifact(
if (action === 'confirm' || action === 'forceLock') values.lockedVersionId = artifact.get('currentVersionId');
await artifact.update(values, { transaction });
const bundled: string[] = [];
for (const row of bundle) {
if (row.get('status') !== 'pending_confirm') continue;
bundled.push(row.get('code'));
await row.update({ status: 'locked', lockedVersionId: row.get('currentVersionId') }, { transaction });
await audit(
ctx,
@@ -325,6 +342,7 @@ export async function transitionArtifact(
{ code: def.code, action, reason, fromStatus: from, toStatus: to, rev: artifact.get('currentRev'), meta: { stale } },
transaction,
);
await notifyTransition(ctx, pc, def, action, { reason, rev: artifact.get('currentRev'), stale, bundled }, transaction);
return { status: to, stale };
});
}
@@ -4,6 +4,7 @@ import type { Context } from '@nocobase/actions';
import { PROJECT_ROLE_LABELS, type ProjectRole } from '../../shared';
import { HttpError, SYSTEM_ROLES, currentUserId, requireOps, systemAccess, userSpaceNames } from './access';
import { notifyAssignment } from './notify';
import { creator, insertUser } from './ops';
const PROJECT_ROLES = Object.keys(PROJECT_ROLE_LABELS) as ProjectRole[];
@@ -131,8 +132,10 @@ export async function createProject(ctx: Context, input: ProjectInput) {
async function upsertMembers(ctx: Context, projectId: number, spaceName: string, list: MemberInput[], transaction: any) {
const repo = ctx.db.getRepository('dz_project_members');
const added: { userId: number; projectRole: ProjectRole; isNew: boolean }[] = [];
for (const m of list) {
const existing = await repo.findOne({ filter: { projectId, userId: m.userId }, transaction });
added.push({ userId: m.userId, projectRole: m.projectRole, isNew: !existing });
if (existing) {
await existing.update({ projectRole: m.projectRole, orgUnitId: m.orgUnitId ?? null }, { transaction });
} else {
@@ -143,6 +146,8 @@ async function upsertMembers(ctx: Context, projectId: number, spaceName: string,
}
}
await addUsersToSpace(ctx, spaceName, list.map((m) => m.userId), transaction);
const project = await ctx.db.getRepository('dz_projects').findOne({ filterByTk: projectId, transaction });
await notifyAssignment(ctx, project, added, transaction);
}
export async function setProjectMembers(ctx: Context, projectId: number, input: unknown) {
@@ -0,0 +1,209 @@
import type { Context } from '@nocobase/actions';
import { type ArtifactDef, type LifecycleAction, PROJECT_ROLE_LABELS, type ProjectRole, getArtifactDef } from '../../shared';
import { type ProjectContext, currentUserId, userSpaceNames } from './access';
// In-app notifications (spec 二(二)5): project name, artifact, action and a direct link.
// Email goes out too when an email channel named `dingze-email` has been configured in
// NocoBase's notification manager; without it only the in-app inbox is used.
export const EMAIL_CHANNEL = 'dingze-email';
export interface NotificationInput {
projectId: number;
spaceName: string;
userIds: number[];
kind: string;
title: string;
content: string;
link: string;
code?: string;
}
function actorName(ctx: Context): string {
const user: any = ctx.auth?.user;
return user?.nickname || user?.username || '有人';
}
export async function notify(ctx: Context, input: NotificationInput, transaction?: any) {
const self = ctx.auth?.user?.id;
const userIds = [...new Set(input.userIds)].filter((id) => id && id !== self);
if (userIds.length === 0) return;
const { userIds: _ignored, ...values } = input;
for (const userId of userIds) {
await ctx.db.getRepository('dz_notifications').create({
values: { ...values, userId, createdById: self, updatedById: self },
transaction,
});
}
sendEmail(ctx, userIds, input, transaction);
}
function sendEmail(ctx: Context, userIds: number[], input: NotificationInput, transaction?: any) {
const manager: any = ctx.app.pm.get('notification-manager');
if (!manager?.send) return;
const run = async () => {
const channel = await ctx.db.getRepository('notificationChannels').findOne({ filter: { name: EMAIL_CHANNEL } });
if (!channel) return;
const users = await ctx.db.getRepository('users').find({ filter: { id: { $in: userIds } }, fields: ['id', 'email'] });
const to = users.map((u: any) => u.get('email')).filter(Boolean);
if (to.length === 0) return;
const origin = (ctx.get?.('origin') as string) || '';
await manager.send({
channelName: EMAIL_CHANNEL,
triggerFrom: 'dingze',
message: { to, subject: `【定三责】${input.title}`, contentType: 'text', content: `${input.content}\n\n${origin}/x/dingze${input.link}` },
});
};
const safe = () => run().catch((error) => ctx.app.logger.warn(`dingze email failed: ${error?.message ?? error}`));
if (transaction?.afterCommit) transaction.afterCommit(safe);
else void safe();
}
/** Project members with one of `roles` (all members when omitted). */
export async function projectMemberIds(ctx: Context, projectId: number, roles?: ProjectRole[], transaction?: any): Promise<number[]> {
const rows = await ctx.db.getRepository('dz_project_members').find({
filter: roles ? { projectId, projectRole: { $in: roles } } : { projectId },
fields: ['userId'],
transaction,
});
return rows.map((r: any) => r.get('userId'));
}
const RECIPIENTS: Partial<Record<LifecycleAction, { roles?: ProjectRole[]; kind: string; title: (name: string) => string; verb: string }>> = {
stepDone: { roles: ['lead_consultant', 'co_consultant'], kind: 'step_done', title: (n) => `《${n}》本步完成,待提交复核`, verb: '标记了本步完成' },
submitReview: { roles: ['lead_consultant'], kind: 'review', title: (n) => `《${n}》待复核`, verb: '提交了复核' },
approve: { roles: ['ent_lead'], kind: 'confirm', title: (n) => `《${n}》复核通过,待企业确认`, verb: '复核通过' },
returnToEdit: { roles: ['ent_lead', 'dept_head', 'lead_consultant'], kind: 'returned', title: (n) => `《${n}》被退回修改`, verb: '退回修改' },
confirm: { kind: 'locked', title: (n) => `《${n}》已定版`, verb: '确认定版' },
forceLock: { kind: 'locked', title: (n) => `《${n}》已定版`, verb: '强制定版' },
reopen: { kind: 'reopened', title: (n) => `《${n}》已解锁重开`, verb: '解锁重开' },
forceReturn: { kind: 'reopened', title: (n) => `《${n}》已退回重做`, verb: '强制退回' },
};
/** “DZ测试燃气 · 2026 年度战略落地”: consultants follow several enterprises at once. */
async function projectLabel(ctx: Context, project: any, transaction?: any): Promise<string> {
const enterprise = await ctx.db.getRepository('dz_enterprises').findOne({ filterByTk: project.get('enterpriseId'), transaction });
const short = enterprise?.get('shortName') || enterprise?.get('name');
return short ? `${short} · ${project.get('name')}` : project.get('name');
}
export async function notifyTransition(
ctx: Context,
pc: ProjectContext,
def: ArtifactDef,
action: LifecycleAction,
extra: { reason?: string; rev?: number; stale: string[]; bundled?: string[] },
transaction: any,
) {
const projectId = pc.project.get('id');
const project = await projectLabel(ctx, pc.project, transaction);
const rule = RECIPIENTS[action];
if (rule) {
const names = [def, ...(extra.bundled ?? []).map((c) => getArtifactDef(c))].map((d) => `${d.specId} ${d.name}`).join('、');
const parts = [`${project} · ${names}:${actorName(ctx)}${rule.verb}`];
if (action === 'confirm' || action === 'forceLock') parts.push(`版本 v${extra.rev}`);
if (extra.reason) parts.push(`原因:${extra.reason}`);
await notify(
ctx,
{
projectId,
spaceName: pc.spaceName,
userIds: await projectMemberIds(ctx, projectId, rule.roles, transaction),
kind: rule.kind,
code: def.code,
title: rule.title(def.name),
content: parts.join(';'),
link: `/projects/${projectId}/workspace/${def.code}`,
},
transaction,
);
}
if (extra.stale.length) {
const owners = await projectMemberIds(ctx, projectId, ['ent_lead', 'lead_consultant'], transaction);
for (const code of extra.stale) {
const target = getArtifactDef(code);
await notify(
ctx,
{
projectId,
spaceName: pc.spaceName,
userIds: owners,
kind: 'stale',
code,
title: `《${target.name}》上游已变更`,
content: `${project} · ${target.specId} ${target.name}:上游 ${def.specId} ${def.name}${action === 'reopen' || action === 'forceReturn' ? '已解锁重开' : '有新版本'},请核对后保存一次`,
link: `/projects/${projectId}/workspace/${code}`,
},
transaction,
);
}
}
}
export async function notifyAssignment(
ctx: Context,
project: any,
members: { userId: number; projectRole: ProjectRole; isNew: boolean }[],
transaction: any,
) {
for (const m of members.filter((x) => x.isNew)) {
await notify(
ctx,
{
projectId: project.get('id'),
spaceName: project.get('spaceName'),
userIds: [m.userId],
kind: 'assigned',
title: `你已加入项目「${project.get('name')}」`,
content: `${actorName(ctx)}把你加入了项目「${await projectLabel(ctx, project, transaction)}」,项目角色:${PROJECT_ROLE_LABELS[m.projectRole] ?? m.projectRole}`,
link: `/projects/${project.get('id')}/overview`,
},
transaction,
);
}
}
/** The current user's notifications in the spaces they still belong to. */
export async function myNotifications(ctx: Context, params: { limit?: unknown; unread?: unknown }) {
const spaces = await userSpaceNames(ctx);
if (spaces.length === 0) return { items: [], unread: 0 };
const filter: Record<string, unknown> = { userId: currentUserId(ctx), spaceName: { $in: spaces } };
const repo = ctx.db.getRepository('dz_notifications');
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
const [items, unread] = await Promise.all([
repo.find({
filter: params.unread === 'true' || params.unread === true ? { ...filter, readAt: { $empty: true } } : filter,
sort: ['-createdAt'],
limit,
appends: ['project'],
}),
repo.count({ filter: { ...filter, readAt: { $empty: true } } }),
]);
return {
unread,
items: items.map((n: any) => ({
id: n.get('id'),
kind: n.get('kind'),
code: n.get('code') ?? null,
title: n.get('title'),
content: n.get('content'),
link: n.get('link'),
projectId: n.get('projectId'),
projectName: n.get('project')?.get('name') ?? '',
createdAt: n.get('createdAt'),
readAt: n.get('readAt') ?? null,
})),
};
}
export async function markNotificationsRead(ctx: Context, input: { ids?: unknown; all?: unknown }) {
const filter: Record<string, unknown> = { userId: currentUserId(ctx), readAt: { $empty: true } };
if (input.all !== true) {
const ids = Array.isArray(input.ids) ? input.ids.map(Number).filter(Number.isInteger) : [];
if (ids.length === 0) return { updated: 0 };
filter.id = { $in: ids };
}
const updated = await ctx.db.getRepository('dz_notifications').update({ filter, values: { readAt: new Date() } });
return { updated: Array.isArray(updated) ? updated.length : updated };
}
@@ -0,0 +1,182 @@
import type { Context } from '@nocobase/actions';
import { ARTIFACTS, type ArtifactDef, type ArtifactStatus, diffPayloads, summarizeDiff } from '../../shared';
import { HttpError, type ProjectContext } from './access';
// 成果管理页 and 交付页 (spec 二(一)7): every artifact's state, versions, operators and the
// difference between the AI draft and the locked version; delivery payloads and export log.
const who = (user: any) => (user ? user.get('nickname') || user.get('username') || '' : '');
export async function artifactRegistry(ctx: Context, pc: ProjectContext) {
const projectId = pc.project.get('id');
const [rows, versions, events] = await Promise.all([
ctx.db.getRepository('dz_artifacts').find({ filter: { projectId } }),
ctx.db.getRepository('dz_artifact_versions').find({
filter: { projectId },
fields: ['id', 'code', 'rev', 'kind', 'createdAt', 'createdById'],
appends: ['createdBy'],
sort: ['code', 'rev'],
}),
ctx.db.getRepository('dz_audit_events').find({
filter: { projectId, code: { $notEmpty: true } },
fields: ['id', 'code', 'action', 'createdAt', 'createdById', 'toStatus'],
appends: ['createdBy'],
sort: ['-createdAt'],
}),
]);
const byCode = new Map(rows.map((r: any) => [r.get('code'), r]));
// AI draft vs locked version: the latest AI version at or before the locked rev.
const diffs = new Map<string, { aiRev: number; lockedRev: number; added: number; removed: number; changed: number }>();
const pairs: { code: string; aiId: number; aiRev: number; lockedId: number; lockedRev: number }[] = [];
for (const row of rows as any[]) {
const lockedId = row.get('lockedVersionId');
if (!lockedId) continue;
const own = versions.filter((v: any) => v.get('code') === row.get('code'));
const locked = own.find((v: any) => v.get('id') === lockedId);
if (!locked) continue;
const ai = own.filter((v: any) => v.get('kind') === 'ai_draft' && v.get('rev') <= locked.get('rev')).pop();
if (ai) pairs.push({ code: row.get('code'), aiId: ai.get('id'), aiRev: ai.get('rev'), lockedId, lockedRev: locked.get('rev') });
}
if (pairs.length) {
const payloads = await ctx.db.getRepository('dz_artifact_versions').find({
filter: { id: { $in: pairs.flatMap((p) => [p.aiId, p.lockedId]) } },
fields: ['id', 'payload'],
});
const payload = new Map(payloads.map((p: any) => [p.get('id'), p.get('payload')]));
for (const p of pairs) {
const summary = summarizeDiff(diffPayloads(payload.get(p.aiId), payload.get(p.lockedId)));
diffs.set(p.code, { aiRev: p.aiRev, lockedRev: p.lockedRev, ...summary });
}
}
return ARTIFACTS.map((def) => {
const row: any = byCode.get(def.code);
const own = versions.filter((v: any) => v.get('code') === def.code);
const last = events.find((e: any) => e.get('code') === def.code);
const lockedId = row?.get('lockedVersionId');
return {
code: def.code,
status: (row?.get('status') as ArtifactStatus) ?? 'not_started',
stale: !!row?.get('stale'),
currentRev: row?.get('currentRev') ?? 0,
lockedRev: own.find((v: any) => v.get('id') === lockedId)?.get('rev') ?? null,
updatedAt: row?.get('updatedAt') ?? null,
versions: {
total: own.length,
ai: own.filter((v: any) => v.get('kind') === 'ai_draft').length,
enterprise: own.filter((v: any) => v.get('kind') === 'enterprise_edit').length,
consultant: own.filter((v: any) => v.get('kind') === 'consultant_revision').length,
},
lastEvent: last ? { action: last.get('action'), at: last.get('createdAt'), by: who(last.get('createdBy')) } : null,
aiDiff: diffs.get(def.code) ?? null,
};
});
}
export async function artifactHistory(ctx: Context, pc: ProjectContext, def: ArtifactDef) {
const projectId = pc.project.get('id');
const [versions, events] = await Promise.all([
ctx.db.getRepository('dz_artifact_versions').find({
filter: { projectId, code: def.code },
fields: ['id', 'rev', 'kind', 'note', 'createdAt', 'createdById'],
appends: ['createdBy'],
sort: ['-rev'],
}),
ctx.db.getRepository('dz_audit_events').find({
filter: { projectId, code: def.code },
appends: ['createdBy'],
sort: ['-createdAt'],
limit: 200,
}),
]);
return {
versions: versions.map((v: any) => ({ rev: v.get('rev'), kind: v.get('kind'), note: v.get('note') ?? '', at: v.get('createdAt'), by: who(v.get('createdBy')) })),
events: events.map((e: any) => ({
action: e.get('action'),
fromStatus: e.get('fromStatus') ?? null,
toStatus: e.get('toStatus') ?? null,
rev: e.get('rev') ?? null,
reason: e.get('reason') ?? '',
at: e.get('createdAt'),
by: who(e.get('createdBy')),
})),
};
}
const MAX_VALUE = 400;
function preview(value: unknown): string {
if (value === undefined || value === null) return '';
const text = typeof value === 'string' ? value : JSON.stringify(value);
return text.length > MAX_VALUE ? `${text.slice(0, MAX_VALUE)}…` : text;
}
export async function versionDiff(ctx: Context, pc: ProjectContext, def: ArtifactDef, fromRev: unknown, toRev: unknown) {
const from = Number(fromRev);
const to = Number(toRev);
if (!Number.isInteger(from) || !Number.isInteger(to)) throw new HttpError(400, '请选择要对比的两个版本');
const rows = await ctx.db.getRepository('dz_artifact_versions').find({
filter: { projectId: pc.project.get('id'), code: def.code, rev: { $in: [from, to] } },
fields: ['rev', 'payload'],
});
const a = rows.find((r: any) => r.get('rev') === from);
const b = rows.find((r: any) => r.get('rev') === to);
if (!a || !b) throw new HttpError(404, '版本不存在');
const entries = diffPayloads(a.get('payload'), b.get('payload'));
return {
from,
to,
summary: summarizeDiff(entries),
entries: entries.slice(0, 300).map((e) => ({ path: e.path, kind: e.kind, before: preview(e.before), after: preview(e.after) })),
truncated: entries.length > 300,
};
}
/** Payload per artifact for delivery exports: the locked version when there is one. */
export async function deliveryBundle(ctx: Context, pc: ProjectContext) {
const projectId = pc.project.get('id');
const [rows, exports] = await Promise.all([
ctx.db.getRepository('dz_artifacts').find({ filter: { projectId }, appends: ['currentVersion', 'lockedVersion'] }),
ctx.db.getRepository('dz_exports').find({ filter: { projectId }, appends: ['createdBy'], sort: ['-createdAt'], limit: 200 }),
]);
return {
artifacts: rows
.filter((r: any) => r.get('currentVersionId'))
.map((r: any) => {
const locked = r.get('lockedVersion');
const current = r.get('currentVersion');
const useLocked = r.get('status') === 'locked' && locked;
const version = useLocked ? locked : current;
return { code: r.get('code'), status: r.get('status'), rev: version?.get('rev') ?? 0, locked: !!useLocked, payload: version?.get('payload') ?? null };
}),
exports: exports.map((e: any) => ({
id: e.get('id'),
code: e.get('code'),
rev: e.get('rev'),
format: e.get('format'),
fileName: e.get('fileName'),
draft: !!e.get('draft'),
at: e.get('createdAt'),
by: who(e.get('createdBy')),
})),
};
}
export async function recordExport(ctx: Context, pc: ProjectContext, def: ArtifactDef, input: { rev?: unknown; format?: unknown; fileName?: unknown; draft?: unknown }) {
const format = input.format === 'docx' ? 'docx' : input.format === 'xlsx' ? 'xlsx' : null;
if (!format) throw new HttpError(400, '未知的导出格式');
await ctx.db.getRepository('dz_exports').create({
values: {
projectId: pc.project.get('id'),
code: def.code,
rev: Number(input.rev) || 0,
format,
fileName: String(input.fileName ?? '').slice(0, 200),
draft: input.draft === true,
spaceName: pc.spaceName,
},
context: ctx,
});
return { ok: true };
}
@@ -0,0 +1,56 @@
// Field-level differences between two versions of an artifact payload. Array items with an
// `id` are matched by id (the same addressing the AI change paths use), so reordering rows
// is not reported as a change; other arrays compare as whole values.
export type DiffKind = 'added' | 'removed' | 'changed';
export interface DiffEntry {
path: string;
kind: DiffKind;
before?: unknown;
after?: unknown;
}
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
const keyed = (v: unknown): v is { id: string }[] =>
Array.isArray(v) && v.length > 0 && v.every((item) => isObject(item) && typeof item.id === 'string');
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
const empty = (v: unknown) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
export function diffPayloads(before: unknown, after: unknown, path = ''): DiffEntry[] {
const join = (key: string) => (path ? `${path}.${key}` : key);
if (isObject(before) && isObject(after)) {
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])];
return keys.flatMap((key) => diffPayloads(before[key], after[key], join(key)));
}
if ((keyed(before) || empty(before)) && (keyed(after) || empty(after)) && (keyed(before) || keyed(after))) {
const a = (before ?? []) as { id: string }[];
const b = (after ?? []) as { id: string }[];
const out: DiffEntry[] = [];
for (const item of a) {
const next = b.find((x) => x.id === item.id);
if (!next) out.push({ path: join(item.id), kind: 'removed', before: item });
else out.push(...diffPayloads(item, next, join(item.id)));
}
for (const item of b) if (!a.some((x) => x.id === item.id)) out.push({ path: join(item.id), kind: 'added', after: item });
return out;
}
if (same(before, after) || (empty(before) && empty(after))) return [];
if (empty(before)) return [{ path, kind: 'added', after }];
if (empty(after)) return [{ path, kind: 'removed', before }];
return [{ path, kind: 'changed', before, after }];
}
export interface DiffSummary {
added: number;
removed: number;
changed: number;
}
export function summarizeDiff(entries: DiffEntry[]): DiffSummary {
return {
added: entries.filter((e) => e.kind === 'added').length,
removed: entries.filter((e) => e.kind === 'removed').length,
changed: entries.filter((e) => e.kind === 'changed').length,
};
}
@@ -5,3 +5,4 @@ export * from './board';
export * from './measures';
export * from './goals';
export * from './actions';
export * from './diff';
@@ -48,6 +48,26 @@ export type LifecycleAction =
| 'forceLock'
| 'forceReturn';
/** Audit and history labels; `grantException` is the consultant's step exception. */
export const ACTION_LABELS: Record<string, string> = {
save: '保存',
stepDone: '本步完成',
submitReview: '提交复核',
approve: '复核通过',
returnToEdit: '退回修改',
confirm: '企业确认定版',
reopen: '解锁重开',
forceLock: '强制定版',
forceReturn: '强制退回',
grantException: '开例外',
};
export const VERSION_KIND_LABELS: Record<string, string> = {
ai_draft: 'AI 初稿',
enterprise_edit: '企业修改',
consultant_revision: '咨询师修订',
};
export interface Actor {
projectRole: ProjectRole | null;
/** System-level consulting admin may force transitions with a reason. */
+3
View File
@@ -11,6 +11,7 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { NotificationBell } from "@/components/dingze/notification-bell";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { Button } from "@/components/ui/button";
import {
@@ -74,6 +75,7 @@ function DesktopHeader() {
</div>
<div className="flex items-center gap-2">
<SettingsLink />
<NotificationBell />
<ThemeToggle />
<UserDropdown />
</div>
@@ -112,6 +114,7 @@ function MobileHeader() {
<Brand logoClassName="h-6" />
<div className="flex shrink-0 items-center gap-1">
<SettingsLink className="size-9" />
<NotificationBell className="size-9" />
<ThemeToggle className="size-9" />
<UserDropdown />
</div>
-3
View File
@@ -312,9 +312,6 @@ function SidebarFooter() {
"keeps it reliable."
)}
</div>
<div className="mt-1 font-mono text-[10px] text-muted-foreground/70">
{__PORTAL_TEMPLATE_NAME__} v{__PORTAL_TEMPLATE_VERSION__}
</div>
</div>
)}
</div>
@@ -0,0 +1,68 @@
import { Bell, CheckCheck } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { formatTime } from "@/lib/dingze/records";
import type { NotificationItem } from "@/lib/dingze/records-api";
import { useMarkNotificationsRead, useMyNotifications } from "@/lib/dingze/records-queries";
import { cn } from "@/lib/utils";
/** 站内通知:待复核、待确认、退回、定版、上游已变更和项目分派,点击直达对应成果。 */
export function NotificationBell({ className }: { className?: string }) {
const notifications = useMyNotifications();
const markRead = useMarkNotificationsRead();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const unread = notifications.data?.unread ?? 0;
const items = notifications.data?.items ?? [];
const openItem = (item: NotificationItem) => {
if (!item.readAt) markRead.mutate({ ids: [item.id] });
setOpen(false);
navigate(item.link);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="icon" aria-label={unread ? `通知(${unread} 条未读)` : "通知"} className={cn("relative", className)}>
<Bell />
{unread ? (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] leading-none font-bold text-white">
{unread > 99 ? "99+" : unread}
</span>
) : null}
</Button>
}
/>
<PopoverContent align="end" className="w-96 gap-0 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="font-semibold">通知</span>
<Button variant="ghost" size="sm" disabled={!unread || markRead.isPending} onClick={() => markRead.mutate({ all: true })}>
<CheckCheck /> 全部已读
</Button>
</div>
<ul className="max-h-[60vh] overflow-y-auto">
{items.map((item) => (
<li key={item.id} className="border-b last:border-b-0">
<button type="button" onClick={() => openItem(item)} className="flex w-full flex-col gap-0.5 px-3 py-2.5 text-left hover:bg-muted">
<span className="flex items-center gap-2">
{!item.readAt ? <span aria-label="未读" className="size-2 shrink-0 rounded-full bg-destructive" /> : null}
<span className={cn("text-sm", !item.readAt && "font-semibold")}>{item.title}</span>
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{item.content}</span>
<span className="text-[11px] text-muted-foreground">
{item.projectName} · {formatTime(item.createdAt)}
</span>
</button>
</li>
))}
{items.length === 0 ? <li className="px-3 py-8 text-center text-sm text-muted-foreground">暂无通知</li> : null}
</ul>
</PopoverContent>
</Popover>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { nocobaseClient } from "@nocobase/portal-sdk/client";
import type { ArtifactStatus, DiffKind } from "@dingze/shared";
import { spaceHeaders } from "./api";
// 成果管理页、交付页与通知的接口。
export type RegistryRow = {
code: string;
status: ArtifactStatus;
stale: boolean;
currentRev: number;
lockedRev: number | null;
updatedAt: string | null;
versions: { total: number; ai: number; enterprise: number; consultant: number };
lastEvent: { action: string; at: string; by: string } | null;
aiDiff: { aiRev: number; lockedRev: number; added: number; removed: number; changed: number } | null;
};
export async function getArtifactRegistry(projectId: number, spaceName: string) {
return nocobaseClient.action<RegistryRow[]>("dingze", "artifactRegistry", { method: "GET", query: { projectId }, headers: spaceHeaders(spaceName) });
}
export type ArtifactHistory = {
versions: { rev: number; kind: string; note: string; at: string; by: string }[];
events: { action: string; fromStatus: ArtifactStatus | null; toStatus: ArtifactStatus | null; rev: number | null; reason: string; at: string; by: string }[];
};
export async function getArtifactHistory(projectId: number, spaceName: string, code: string) {
return nocobaseClient.action<ArtifactHistory>("dingze", "artifactHistory", { method: "GET", query: { projectId, code }, headers: spaceHeaders(spaceName) });
}
export type VersionDiff = {
from: number;
to: number;
summary: { added: number; removed: number; changed: number };
entries: { path: string; kind: DiffKind; before: string; after: string }[];
truncated: boolean;
};
export async function getVersionDiff(projectId: number, spaceName: string, code: string, from: number, to: number) {
return nocobaseClient.action<VersionDiff>("dingze", "versionDiff", { method: "GET", query: { projectId, code, from, to }, headers: spaceHeaders(spaceName) });
}
export type DeliveryArtifact = { code: string; status: ArtifactStatus; rev: number; locked: boolean; payload: unknown };
export type ExportRecord = { id: number; code: string; rev: number; format: "xlsx" | "docx"; fileName: string; draft: boolean; at: string; by: string };
export async function getDeliveryBundle(projectId: number, spaceName: string) {
return nocobaseClient.action<{ artifacts: DeliveryArtifact[]; exports: ExportRecord[] }>("dingze", "deliveryBundle", {
method: "GET",
query: { projectId },
headers: spaceHeaders(spaceName),
});
}
export type ExportInfo = { code: string; rev: number; format: "xlsx" | "docx"; fileName: string; draft: boolean };
export async function recordExport(projectId: number, spaceName: string, info: ExportInfo) {
return nocobaseClient.action("dingze", "recordExport", { body: { projectId, ...info }, headers: spaceHeaders(spaceName) });
}
export type NotificationItem = {
id: number;
kind: string;
code: string | null;
title: string;
content: string;
link: string;
projectId: number;
projectName: string;
createdAt: string;
readAt: string | null;
};
/** Cross-enterprise like the board: the plugin resolves the caller's spaces itself. */
export async function getMyNotifications(limit = 30) {
return nocobaseClient.action<{ unread: number; items: NotificationItem[] }>("dingze", "myNotifications", { method: "GET", query: { limit } });
}
export async function markNotificationsRead(values: { ids?: number[]; all?: boolean }) {
return nocobaseClient.action<{ updated: number }>("dingze", "markNotificationsRead", { body: values });
}
+69
View File
@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ProjectSummary } from "./api";
import {
type ExportInfo,
getArtifactHistory,
getArtifactRegistry,
getDeliveryBundle,
getMyNotifications,
getVersionDiff,
markNotificationsRead,
recordExport,
} from "./records-api";
export const recordKeys = {
registry: (projectId: number) => ["dingze", "registry", projectId] as const,
history: (projectId: number, code: string) => ["dingze", "history", projectId, code] as const,
diff: (projectId: number, code: string, from: number, to: number) => ["dingze", "diff", projectId, code, from, to] as const,
delivery: (projectId: number) => ["dingze", "delivery", projectId] as const,
notifications: ["dingze", "notifications"] as const,
};
export function useArtifactRegistry(project: ProjectSummary) {
return useQuery({ queryKey: recordKeys.registry(project.id), queryFn: () => getArtifactRegistry(project.id, project.spaceName) });
}
export function useArtifactHistory(project: ProjectSummary, code: string | null) {
return useQuery({
queryKey: recordKeys.history(project.id, code ?? ""),
queryFn: () => getArtifactHistory(project.id, project.spaceName, code!),
enabled: !!code,
});
}
export function useVersionDiff(project: ProjectSummary, code: string | null, from: number | null, to: number | null) {
return useQuery({
queryKey: recordKeys.diff(project.id, code ?? "", from ?? 0, to ?? 0),
queryFn: () => getVersionDiff(project.id, project.spaceName, code!, from!, to!),
enabled: !!code && !!from && !!to && from !== to,
staleTime: Infinity,
});
}
export function useDeliveryBundle(project: ProjectSummary) {
return useQuery({ queryKey: recordKeys.delivery(project.id), queryFn: () => getDeliveryBundle(project.id, project.spaceName) });
}
/** Log an export (best effort: a failed log never blocks the download the user already has). */
export function useRecordExport(project: ProjectSummary | undefined) {
const client = useQueryClient();
return (info: ExportInfo) => {
if (!project) return;
recordExport(project.id, project.spaceName, info)
.then(() => client.invalidateQueries({ queryKey: recordKeys.delivery(project.id) }))
.catch(() => undefined);
};
}
export function useMyNotifications() {
return useQuery({ queryKey: recordKeys.notifications, queryFn: () => getMyNotifications(30), refetchInterval: 60_000, refetchOnWindowFocus: true });
}
export function useMarkNotificationsRead() {
const client = useQueryClient();
return useMutation({
mutationFn: markNotificationsRead,
onSuccess: () => client.invalidateQueries({ queryKey: recordKeys.notifications }),
});
}
+95
View File
@@ -0,0 +1,95 @@
import { ACTION_LABELS, ARTIFACTS, STAGES, STATUS_LABELS, type ArtifactStatus, type StageKey, VERSION_KIND_LABELS } from "@dingze/shared";
import type { SheetModel } from "./export/sheets";
import type { RegistryRow } from "./records-api";
// Pure helpers behind the 成果管理页 (filtering, labels, the exported list).
export type RegistryFilter = { stage: StageKey | "all"; status: ArtifactStatus | "stale" | "all" };
/** P0 / P1, or 方法底稿 for method worksheets. */
export function priorityLabel(priority: string): string {
return priority === "method" ? "方法底稿" : priority;
}
export function formatTime(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function filterRegistry(rows: RegistryRow[], filter: RegistryFilter): RegistryRow[] {
return rows.filter((row) => {
const def = ARTIFACTS.find((a) => a.code === row.code);
if (!def) return false;
if (filter.stage !== "all" && def.stage !== filter.stage) return false;
if (filter.status === "stale") return row.stale;
if (filter.status !== "all" && row.status !== filter.status) return false;
return true;
});
}
export function aiDiffLabel(diff: RegistryRow["aiDiff"]): string {
if (!diff) return "—";
const total = diff.added + diff.removed + diff.changed;
if (total === 0) return `v${diff.aiRev}→v${diff.lockedRev} 未改动`;
const parts = [diff.changed && `改 ${diff.changed}`, diff.added && `增 ${diff.added}`, diff.removed && `删 ${diff.removed}`].filter(Boolean);
return `v${diff.aiRev}→v${diff.lockedRev} ${parts.join(" · ")}`;
}
export function versionMix(v: RegistryRow["versions"]): string {
if (v.total === 0) return "—";
const parts = [
v.ai && `${VERSION_KIND_LABELS.ai_draft} ${v.ai}`,
v.enterprise && `${VERSION_KIND_LABELS.enterprise_edit} ${v.enterprise}`,
v.consultant && `${VERSION_KIND_LABELS.consultant_revision} ${v.consultant}`,
].filter(Boolean);
return parts.join(" · ");
}
export function eventLabel(event: RegistryRow["lastEvent"]): string {
if (!event) return "—";
return `${event.by || "—"} ${ACTION_LABELS[event.action] ?? event.action}`;
}
/** The registry as one sheet for “导出清单”. */
export function registrySheet(rows: RegistryRow[]): SheetModel {
const stageName = (key: StageKey) => STAGES.find((s) => s.key === key)?.name ?? key;
return {
name: "成果清单",
columns: [
["阶段", 10],
["编号", 10],
["成果", 28],
["级别", 6],
["状态", 10],
["上游已变更", 10],
["当前版本", 8],
["定版版本", 8],
["版本构成", 30],
["AI 初稿→定版", 22],
["最近操作", 20],
["最近操作时间", 18],
].map(([header, width]) => ({ header: header as string, width: width as number })),
rows: rows.map((row) => {
const def = ARTIFACTS.find((a) => a.code === row.code)!;
return [
stageName(def.stage),
def.specId,
def.name,
priorityLabel(def.priority),
STATUS_LABELS[row.status],
row.stale ? "是" : null,
row.currentRev || null,
row.lockedRev,
versionMix(row.versions),
aiDiffLabel(row.aiDiff),
eventLabel(row.lastEvent),
formatTime(row.lastEvent?.at ?? row.updatedAt),
];
}),
merges: [],
};
}
+165
View File
@@ -0,0 +1,165 @@
import { FileSpreadsheet, History } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { ARTIFACTS, STAGES, STATUS_LABELS, type ArtifactStatus, type StageKey } from "@dingze/shared";
import { StatusBadge } from "@/components/dingze/status-badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Skeleton } from "@/components/ui/skeleton";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { errorMessage } from "@/lib/dingze/errors";
import { downloadXlsx } from "@/lib/dingze/export/xlsx";
import { workspacePath } from "@/lib/dingze/progress";
import { type RegistryFilter, aiDiffLabel, eventLabel, filterRegistry, formatTime, priorityLabel, registrySheet, versionMix } from "@/lib/dingze/records";
import { useArtifactRegistry } from "@/lib/dingze/records-queries";
import { ArtifactHistorySheet } from "./components/artifact-history";
import { useProjectContext } from "./project-context";
const STATUS_FILTERS: { value: RegistryFilter["status"]; label: string }[] = [
{ value: "all", label: "全部状态" },
...(["not_started", "in_progress", "step_done", "in_review", "pending_confirm", "locked"] as ArtifactStatus[]).map((s) => ({ value: s, label: STATUS_LABELS[s] })),
{ value: "stale", label: "上游已变更" },
];
/** 成果与定版:every artifact of the project with state, versions, operators and AI-vs-locked difference. */
export default function ArtifactsPage() {
const { project } = useProjectContext();
const registry = useArtifactRegistry(project);
const [filter, setFilter] = useState<RegistryFilter>({ stage: "all", status: "all" });
const [historyCode, setHistoryCode] = useState<string | null>(null);
const rows = useMemo(() => filterRegistry(registry.data ?? [], filter), [registry.data, filter]);
const all = registry.data ?? [];
const locked = all.filter((r) => r.status === "locked").length;
const exportList = async () => {
try {
const stamp = formatTime(new Date().toISOString());
await downloadXlsx(
`${project.enterprise?.shortName ?? "企业"}-成果清单.xlsx`.replace(/[\\/:*?"<>|]/g, "_"),
{ title: "成果清单", subtitle: `${project.enterprise?.shortName ?? ""} · ${project.name} · 导出于 ${stamp}`, draft: false },
[registrySheet(rows)]
);
} catch (error) {
toast.error(`导出失败:${errorMessage(error)}`);
}
};
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 p-4 md:p-6">
<div className="flex flex-wrap items-end gap-3">
<div>
<h1 className="font-heading text-2xl font-bold text-brand">成果与定版</h1>
<p className="mt-1 text-sm text-muted-foreground">
全部 {ARTIFACTS.length} 项成果的状态、版本与操作者;AI 初稿与定版的差异用于方法和规则改进。已定版 {locked} / {ARTIFACTS.length}。
</p>
</div>
<Button variant="outline" className="ml-auto" onClick={exportList} disabled={rows.length === 0}>
<FileSpreadsheet /> 导出清单
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<ToggleGroup
value={[filter.stage]}
onValueChange={(v) => v[0] && setFilter((f) => ({ ...f, stage: v[0] as StageKey | "all" }))}
variant="outline"
size="sm"
aria-label="按阶段筛选"
>
<ToggleGroupItem value="all">全部阶段</ToggleGroupItem>
{STAGES.map((s) => (
<ToggleGroupItem key={s.key} value={s.key}>
{s.name}
</ToggleGroupItem>
))}
</ToggleGroup>
<NativeSelect size="sm" aria-label="按状态筛选" value={filter.status} onChange={(e) => setFilter((f) => ({ ...f, status: e.target.value as RegistryFilter["status"] }))}>
{STATUS_FILTERS.map((s) => (
<NativeSelectOption key={s.value} value={s.value}>
{s.label}
</NativeSelectOption>
))}
</NativeSelect>
<span className="text-xs text-muted-foreground">{rows.length} 项</span>
</div>
{registry.error ? (
<Alert variant="destructive">
<AlertTitle>成果清单加载失败</AlertTitle>
<AlertDescription>{errorMessage(registry.error)}</AlertDescription>
</Alert>
) : registry.isLoading ? (
<Skeleton className="h-96 rounded-xl" />
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[980px] border-collapse text-sm">
<thead className="bg-muted text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-semibold">成果</th>
<th className="px-3 py-2 text-left font-semibold">状态</th>
<th className="px-3 py-2 text-left font-semibold">版本</th>
<th className="px-3 py-2 text-left font-semibold">版本构成</th>
<th className="px-3 py-2 text-left font-semibold">AI 初稿 → 定版</th>
<th className="px-3 py-2 text-left font-semibold">最近操作</th>
<th className="w-20" />
</tr>
</thead>
<tbody>
{rows.map((row) => {
const def = ARTIFACTS.find((a) => a.code === row.code)!;
return (
<tr key={row.code} className="border-t align-top">
<td className="px-3 py-2">
<Link to={workspacePath(project.id, row.code)} className="font-medium text-brand hover:underline">
《{def.name}》
</Link>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{def.specId}
<Badge variant="outline" className="h-4 px-1 text-[10px]">
{priorityLabel(def.priority)}
</Badge>
{STAGES.find((s) => s.key === def.stage)?.name}
</div>
</td>
<td className="px-3 py-2">
<StatusBadge status={row.status} stale={row.stale} />
</td>
<td className="px-3 py-2 text-xs tabular-nums">
{row.currentRev ? `当前 v${row.currentRev}` : "—"}
{row.lockedRev ? <div className="text-muted-foreground">定版 v{row.lockedRev}</div> : null}
</td>
<td className="px-3 py-2 text-xs">{versionMix(row.versions)}</td>
<td className="px-3 py-2 text-xs">{aiDiffLabel(row.aiDiff)}</td>
<td className="px-3 py-2 text-xs">
{eventLabel(row.lastEvent)}
<div className="text-muted-foreground">{formatTime(row.lastEvent?.at ?? row.updatedAt)}</div>
</td>
<td className="px-2 py-1.5">
<Button variant="ghost" size="sm" disabled={row.versions.total === 0} onClick={() => setHistoryCode(row.code)}>
<History /> 历史
</Button>
</td>
</tr>
);
})}
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-muted-foreground">
没有符合筛选条件的成果
</td>
</tr>
) : null}
</tbody>
</table>
</div>
)}
<ArtifactHistorySheet project={project} code={historyCode} onClose={() => setHistoryCode(null)} />
</div>
);
}
@@ -0,0 +1,154 @@
import { useState } from "react";
import { ACTION_LABELS, STATUS_LABELS, VERSION_KIND_LABELS, getArtifactDef } from "@dingze/shared";
import { Badge } from "@/components/ui/badge";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ProjectSummary } from "@/lib/dingze/api";
import { errorMessage } from "@/lib/dingze/errors";
import { formatTime } from "@/lib/dingze/records";
import { useArtifactHistory, useVersionDiff } from "@/lib/dingze/records-queries";
import { cn } from "@/lib/utils";
const KIND_TONE: Record<string, string> = {
added: "bg-status-done text-status-done-foreground",
removed: "bg-status-stale text-status-stale-foreground",
changed: "bg-status-progress text-status-progress-foreground",
};
const KIND_LABEL: Record<string, string> = { added: "新增", removed: "删除", changed: "修改" };
/** Versions, operations and a two-version comparison for one artifact. */
export function ArtifactHistorySheet({
project,
code,
onClose,
}: {
project: ProjectSummary;
code: string | null;
onClose: () => void;
}) {
const history = useArtifactHistory(project, code);
const versions = history.data?.versions ?? [];
const [pair, setPair] = useState<{ code: string; from: number; to: number } | null>(null);
// Default comparison: the first AI draft (or first version) against the latest.
const firstAi = [...versions].reverse().find((v) => v.kind === "ai_draft") ?? versions[versions.length - 1];
const defaults = versions.length >= 2 ? { from: firstAi?.rev ?? versions[versions.length - 1].rev, to: versions[0].rev } : null;
const current = pair && pair.code === code ? pair : defaults && code ? { code, ...defaults } : null;
const diff = useVersionDiff(project, code, current?.from ?? null, current?.to ?? null);
const def = code ? getArtifactDef(code) : null;
return (
<Sheet open={!!code} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full gap-0 overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{def ? `${def.specId} 《${def.name}》` : ""}</SheetTitle>
<SheetDescription>版本、操作记录和任意两个版本的差异。</SheetDescription>
</SheetHeader>
<div className="px-4 pb-6">
{history.error ? (
<p className="text-sm text-destructive">{errorMessage(history.error)}</p>
) : history.isLoading ? (
<Skeleton className="h-48" />
) : (
<Tabs defaultValue="versions">
<TabsList>
<TabsTrigger value="versions">版本({versions.length})</TabsTrigger>
<TabsTrigger value="diff">版本对比</TabsTrigger>
<TabsTrigger value="events">操作记录</TabsTrigger>
</TabsList>
<TabsContent value="versions" className="pt-3">
<ol className="flex flex-col gap-2">
{versions.map((v) => (
<li key={v.rev} className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<span className="font-semibold tabular-nums">v{v.rev}</span>
<Badge variant={v.kind === "ai_draft" ? "secondary" : "outline"}>{VERSION_KIND_LABELS[v.kind] ?? v.kind}</Badge>
<span>{v.by || "—"}</span>
<span className="text-muted-foreground">{formatTime(v.at)}</span>
{v.note ? <span className="basis-full text-xs text-muted-foreground">{v.note}</span> : null}
</li>
))}
{versions.length === 0 ? <li className="text-sm text-muted-foreground">还没有版本</li> : null}
</ol>
</TabsContent>
<TabsContent value="diff" className="flex flex-col gap-3 pt-3">
{versions.length < 2 || !current ? (
<p className="text-sm text-muted-foreground">至少需要两个版本才能对比。</p>
) : (
<>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span>从</span>
<NativeSelect size="sm" aria-label="对比起点版本" value={String(current.from)} onChange={(e) => setPair({ code: code!, from: Number(e.target.value), to: current.to })}>
{versions.map((v) => (
<NativeSelectOption key={v.rev} value={String(v.rev)}>
v{v.rev} {VERSION_KIND_LABELS[v.kind] ?? ""}
</NativeSelectOption>
))}
</NativeSelect>
<span>到</span>
<NativeSelect size="sm" aria-label="对比终点版本" value={String(current.to)} onChange={(e) => setPair({ code: code!, from: current.from, to: Number(e.target.value) })}>
{versions.map((v) => (
<NativeSelectOption key={v.rev} value={String(v.rev)}>
v{v.rev} {VERSION_KIND_LABELS[v.kind] ?? ""}
</NativeSelectOption>
))}
</NativeSelect>
{diff.data ? (
<span className="text-xs text-muted-foreground">
修改 {diff.data.summary.changed} · 新增 {diff.data.summary.added} · 删除 {diff.data.summary.removed}
</span>
) : null}
</div>
{current.from === current.to ? (
<p className="text-sm text-muted-foreground">选两个不同的版本。</p>
) : diff.isLoading ? (
<Skeleton className="h-32" />
) : diff.error ? (
<p className="text-sm text-destructive">{errorMessage(diff.error)}</p>
) : diff.data?.entries.length === 0 ? (
<p className="text-sm text-muted-foreground">两个版本内容相同。</p>
) : (
<ul className="flex flex-col gap-2">
{diff.data?.entries.map((e, i) => (
<li key={i} className="rounded-lg border p-2 text-xs">
<div className="mb-1 flex items-center gap-2">
<Badge className={cn("font-semibold", KIND_TONE[e.kind])}>{KIND_LABEL[e.kind]}</Badge>
<code className="break-all text-muted-foreground">{e.path}</code>
</div>
{e.before ? <div className="break-all text-muted-foreground line-through">{e.before}</div> : null}
{e.after ? <div className="break-all">{e.after}</div> : null}
</li>
))}
{diff.data?.truncated ? <li className="text-xs text-muted-foreground">差异较多,只列出前 300 处。</li> : null}
</ul>
)}
</>
)}
</TabsContent>
<TabsContent value="events" className="pt-3">
<ol className="flex flex-col gap-2">
{(history.data?.events ?? []).map((e, i) => (
<li key={i} className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<span className="font-semibold">{ACTION_LABELS[e.action] ?? e.action}</span>
{e.rev ? <span className="tabular-nums text-muted-foreground">v{e.rev}</span> : null}
{e.fromStatus && e.toStatus && e.fromStatus !== e.toStatus ? (
<span className="text-xs text-muted-foreground">
{STATUS_LABELS[e.fromStatus]} → {STATUS_LABELS[e.toStatus]}
</span>
) : null}
<span>{e.by || "—"}</span>
<span className="text-muted-foreground">{formatTime(e.at)}</span>
{e.reason ? <span className="basis-full text-xs">原因:{e.reason}</span> : null}
</li>
))}
</ol>
</TabsContent>
</Tabs>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { artifactSheets } from "@/lib/dingze/export/sheets";
import { downloadXlsx, exportFileName } from "@/lib/dingze/export/xlsx";
import { errorMessage } from "@/lib/dingze/errors";
import type { ExportInfo } from "@/lib/dingze/records-api";
/**
* Exports the saved version (not unsaved edits) to Excel. Anything short of 已定版 is
@@ -23,6 +24,8 @@ export function ExportButton({
projectName,
upstream,
dirty,
onExported,
label = "导出 Excel",
}: {
code: string;
name: string;
@@ -33,6 +36,8 @@ export function ExportButton({
projectName: string;
upstream: Record<string, unknown>;
dirty: boolean;
onExported?: (info: ExportInfo) => void;
label?: string;
}) {
const [pending, setPending] = useState(false);
const sheets = artifactSheets(code, payload, upstream);
@@ -42,8 +47,9 @@ export function ExportButton({
setPending(true);
try {
const stamp = new Date().toLocaleString("zh-CN", { hour12: false });
const fileName = exportFileName(enterprise, code, name, rev);
await downloadXlsx(
exportFileName(enterprise, code, name, rev),
fileName,
{
title: `《${name}》`,
subtitle: `${enterprise} · ${projectName} · ${STATUS_LABELS[status]} v${rev} · 导出于 ${stamp}`,
@@ -51,6 +57,7 @@ export function ExportButton({
},
sheets
);
onExported?.({ code, rev, format: "xlsx", fileName, draft: status !== "locked" });
if (dirty) toast.info("已导出最近一次保存的版本,未保存的修改不在文件里");
} catch (error) {
toast.error(`导出失败:${errorMessage(error)}`);
@@ -61,7 +68,7 @@ export function ExportButton({
return (
<Button variant="outline" onClick={run} disabled={pending}>
<FileSpreadsheet /> {pending ? "导出中…" : "导出 Excel"}
<FileSpreadsheet /> {pending ? "导出中…" : label}
</Button>
);
}
@@ -7,6 +7,7 @@ import { type ArtifactStatus, type CompanyPlanBook, type DeptPlanBook, emptyPlan
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 PlanBookUpstream, buildCompanyPlanBook, buildDeptPlanBook, deptText } from "@/lib/dingze/plan-book";
const safe = (name: string) => name.replace(/[\\/:*?"<>|]/g, "_");
@@ -21,6 +22,7 @@ export function PlanBookExportButton({
year,
upstream,
dirty,
onExported,
}: {
code: string;
payload: unknown;
@@ -30,6 +32,7 @@ export function PlanBookExportButton({
year: number;
upstream: Record<string, unknown>;
dirty: boolean;
onExported?: (info: ExportInfo) => void;
}) {
const [pending, setPending] = useState(false);
if ((code !== "S3-07" && code !== "S3-08") || rev === 0 || !payload) return null;
@@ -41,7 +44,9 @@ export function PlanBookExportButton({
try {
if (code === "S3-07") {
const text = (payload as CompanyPlanBook).text ?? emptyPlanBookText();
await downloadPlanBookDocx(safe(`${enterprise}-${year}年度经营计划书-v${rev}.docx`), buildCompanyPlanBook({ enterprise, year, text, upstream: u }), draft);
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 });
} else {
const depts = (payload as DeptPlanBook).depts ?? [];
if (depts.length === 0) {
@@ -51,11 +56,9 @@ export function PlanBookExportButton({
for (const d of depts) {
const { deptId, deptName } = d;
const text = deptText(d);
await downloadPlanBookDocx(
safe(`${enterprise}-${deptName}-${year}年度经营计划书-v${rev}.docx`),
buildDeptPlanBook({ enterprise, year, dept: { deptId, deptName }, text, upstream: u }),
draft
);
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 });
}
}
if (dirty) toast.info("已导出最近一次保存的版本,未保存的修改不在文件里");
+238
View File
@@ -0,0 +1,238 @@
import { FileSpreadsheet } from "lucide-react";
import { useMemo } from "react";
import { toast } from "sonner";
import { ARTIFACTS, STAGES, STATUS_LABELS, type ScorecardSet, cardWeight, getArtifactDef } from "@dingze/shared";
import { StatusBadge } from "@/components/dingze/status-badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { errorMessage } from "@/lib/dingze/errors";
import { artifactSheets } from "@/lib/dingze/export/sheets";
import { downloadXlsx } from "@/lib/dingze/export/xlsx";
import { formatTime } from "@/lib/dingze/records";
import type { DeliveryArtifact } from "@/lib/dingze/records-api";
import { useDeliveryBundle, useRecordExport } from "@/lib/dingze/records-queries";
import { ExportButton } from "./components/export-button";
import { PlanBookExportButton } from "./components/plan-book-export";
import { useProjectContext } from "./project-context";
const PLAN_BOOKS = ["S3-07", "S3-08"];
/** 交付:正式交付物(定版版本,未定版为草稿)、各部门绩效计分卡和导出记录。 */
export default function DeliveryPage() {
const { project } = useProjectContext();
const bundle = useDeliveryBundle(project);
const recordExport = useRecordExport(project);
const enterprise = project.enterprise?.shortName ?? "企业";
const { byCode, upstream } = useMemo(() => {
const map = new Map<string, DeliveryArtifact>();
const payloads: Record<string, unknown> = {};
for (const a of bundle.data?.artifacts ?? []) {
map.set(a.code, a);
if (a.payload) payloads[a.code] = a.payload;
}
return { byCode: map, upstream: payloads };
}, [bundle.data]);
const deliverables = ARTIFACTS.filter((def) => {
const a = byCode.get(def.code);
if (!a?.payload) return false;
return PLAN_BOOKS.includes(def.code) || artifactSheets(def.code, a.payload, upstream).length > 0;
});
const scorecards = byCode.get("S2-07");
const cards = ((scorecards?.payload as ScorecardSet | undefined)?.cards ?? []).filter((c) => (c.items ?? []).length > 0);
const exportCard = async (deptId: string) => {
if (!scorecards) return;
const set = scorecards.payload as ScorecardSet;
const card = set.cards.find((c) => c.deptId === deptId);
if (!card) return;
const fileName = `${enterprise}-${card.deptName}-绩效计分卡-v${scorecards.rev}.xlsx`.replace(/[\\/:*?"<>|]/g, "_");
try {
await downloadXlsx(
fileName,
{
title: `《${card.deptName}绩效计分卡》`,
subtitle: `${enterprise} · ${project.name} · ${STATUS_LABELS[scorecards.status]} v${scorecards.rev} · 导出于 ${formatTime(new Date().toISOString())}`,
draft: !scorecards.locked,
},
artifactSheets("S2-07", { cards: [card] })
);
recordExport({ code: "S2-07", rev: scorecards.rev, format: "xlsx", fileName, draft: !scorecards.locked });
} catch (error) {
toast.error(`导出失败:${errorMessage(error)}`);
}
};
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 md:p-6">
<div>
<h1 className="font-heading text-2xl font-bold text-brand">交付</h1>
<p className="mt-1 text-sm text-muted-foreground">
正式交付物按定版版本导出;尚未定版的成果导出为带“草稿”标记的文件,避免被当作定版件流转。
</p>
</div>
{bundle.error ? (
<Alert variant="destructive">
<AlertTitle>交付物加载失败</AlertTitle>
<AlertDescription>{errorMessage(bundle.error)}</AlertDescription>
</Alert>
) : bundle.isLoading ? (
<Skeleton className="h-96 rounded-xl" />
) : (
<>
<section aria-labelledby="deliverables" className="flex flex-col gap-3">
<h2 id="deliverables" className="font-heading text-lg font-bold text-brand">
正式交付物
</h2>
{STAGES.map((stage) => {
const items = deliverables.filter((d) => d.stage === stage.key);
if (items.length === 0) return null;
return (
<div key={stage.key} className="overflow-hidden rounded-xl border bg-card">
<div className="bg-muted px-4 py-2 text-sm font-semibold">{stage.name}</div>
<ul>
{items.map((def) => {
const a = byCode.get(def.code)!;
return (
<li key={def.code} className="flex flex-wrap items-center gap-3 border-t px-4 py-3">
<div className="min-w-56 flex-1">
<div className="font-medium">《{def.name}》</div>
<div className="text-xs text-muted-foreground">
{def.specId} · {a.locked ? `定版 v${a.rev}` : `草稿 v${a.rev}`}
</div>
</div>
<StatusBadge status={a.status} />
<div className="flex gap-2">
<ExportButton
code={def.code}
name={def.name}
payload={a.payload}
rev={a.rev}
status={a.locked ? "locked" : a.status}
enterprise={enterprise}
projectName={project.name}
upstream={upstream}
dirty={false}
onExported={recordExport}
/>
<PlanBookExportButton
code={def.code}
payload={a.payload}
rev={a.rev}
status={a.locked ? "locked" : a.status}
enterprise={project.enterprise?.name ?? enterprise}
year={project.year}
upstream={upstream}
dirty={false}
onExported={recordExport}
/>
</div>
</li>
);
})}
</ul>
</div>
);
})}
{deliverables.length === 0 ? <p className="text-sm text-muted-foreground">还没有可以导出的成果。</p> : null}
</section>
<section aria-labelledby="scorecards" className="flex flex-col gap-3">
<h2 id="scorecards" className="font-heading text-lg font-bold text-brand">
各部门绩效计分卡
</h2>
{cards.length === 0 ? (
<p className="text-sm text-muted-foreground">《绩效计分卡》还没有内容。</p>
) : (
<div className="grid gap-4 md:grid-cols-2">
{cards.map((card) => (
<Card key={card.deptId}>
<CardHeader className="flex flex-row items-start justify-between gap-2">
<div>
<CardTitle>{card.deptName}</CardTitle>
<CardDescription>
{card.items.length} 项指标 · 权重合计 {cardWeight(card)}
{scorecards?.locked ? ` · 定版 v${scorecards.rev}` : " · 草稿"}
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => exportCard(card.deptId)}>
<FileSpreadsheet /> Excel
</Button>
</CardHeader>
<CardContent>
<table className="w-full text-xs">
<thead className="text-muted-foreground">
<tr>
<th className="py-1 text-left font-semibold">衡量指标</th>
<th className="py-1 text-left font-semibold">保底</th>
<th className="py-1 text-left font-semibold">力争</th>
<th className="py-1 text-right font-semibold">权重</th>
</tr>
</thead>
<tbody>
{card.items.map((item) => (
<tr key={item.id} className="border-t">
<td className="py-1">{item.metric}</td>
<td className="py-1">{item.floor}</td>
<td className="py-1">{item.target}</td>
<td className="py-1 text-right tabular-nums">{item.weight ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
))}
</div>
)}
</section>
<section aria-labelledby="exports" className="flex flex-col gap-3">
<h2 id="exports" className="font-heading text-lg font-bold text-brand">
导出记录
</h2>
{(bundle.data?.exports ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">还没有导出过。</p>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[720px] text-sm">
<thead className="bg-muted text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-semibold">时间</th>
<th className="px-3 py-2 text-left font-semibold">成果</th>
<th className="px-3 py-2 text-left font-semibold">文件</th>
<th className="px-3 py-2 text-left font-semibold">版本</th>
<th className="px-3 py-2 text-left font-semibold">导出人</th>
</tr>
</thead>
<tbody>
{bundle.data!.exports.map((e) => (
<tr key={e.id} className="border-t">
<td className="px-3 py-2 text-xs tabular-nums">{formatTime(e.at)}</td>
<td className="px-3 py-2">{getArtifactDef(e.code)?.name ?? e.code}</td>
<td className="px-3 py-2 text-xs break-all">{e.fileName}</td>
<td className="px-3 py-2 text-xs">
v{e.rev} {e.draft ? <Badge variant="outline">草稿</Badge> : <Badge variant="secondary">定版</Badge>}
</td>
<td className="px-3 py-2 text-xs">{e.by || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@ import { ChevronLeft } from "lucide-react";
import { Link, NavLink, Outlet, useParams } from "react-router";
import { UserDropdown } from "@/components/app-shell/header";
import { NotificationBell } from "@/components/dingze/notification-bell";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
@@ -14,6 +15,8 @@ import type { ProjectContextValue } from "./project-context";
const NAV = [
{ to: "overview", label: "工作台" },
{ to: "workspace", label: "咨询工作区" },
{ to: "artifacts", label: "成果与定版" },
{ to: "delivery", label: "交付" },
];
export default function ProjectLayout() {
@@ -69,6 +72,7 @@ export default function ProjectLayout() {
{PROJECT_ROLE_LABELS[overview.data.projectRole]}
</span>
) : null}
<NotificationBell className="text-band-foreground hover:bg-white/10 hover:text-band-foreground" />
<ThemeToggle className="border-white/25 bg-white/10 text-band-foreground hover:bg-white/20" />
<UserDropdown />
</div>
+4
View File
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { nextStep, workspacePath } from "@/lib/dingze/progress";
import { useArtifactDetail, useSaveArtifact, useUpstreamPayloads } from "@/lib/dingze/queries";
import { useRecordExport } from "@/lib/dingze/records-queries";
import { EDITABLE_CODES, emptyPayload } from "@/lib/dingze/artifact-payloads";
import { type ProposedChange, applyChanges } from "@/lib/dingze/changes";
@@ -81,6 +82,7 @@ function Workspace({ code }: { code: string }) {
const [draft, setDraft] = useState<unknown>(serverPayload);
const [baseRev, setBaseRev] = useState(serverRev);
const dirty = JSON.stringify(draft) !== JSON.stringify(serverPayload);
const recordExport = useRecordExport(project);
// Adopt the server version when it changes and nothing local is pending.
useEffect(() => {
@@ -301,6 +303,7 @@ function Workspace({ code }: { code: string }) {
projectName={project.name}
upstream={upstream}
dirty={dirty}
onExported={recordExport}
/>
<PlanBookExportButton
code={code}
@@ -311,6 +314,7 @@ function Workspace({ code }: { code: string }) {
year={project.year}
upstream={upstream}
dirty={dirty}
onExported={recordExport}
/>
{!readOnly && EDITABLE_CODES.has(code) ? (
<Button
+2
View File
@@ -36,6 +36,8 @@ export const appRoutes = defineAppRoutes([
{ name: "project.overview", path: "overview", lazy: () => import("@/pages/projects/overview") },
{ name: "project.workspace", path: "workspace", lazy: () => import("@/pages/projects/workspace") },
{ name: "project.workspace.artifact", path: "workspace/:code", lazy: () => import("@/pages/projects/workspace") },
{ name: "project.artifacts", path: "artifacts", lazy: () => import("@/pages/projects/artifacts") },
{ name: "project.delivery", path: "delivery", lazy: () => import("@/pages/projects/delivery") },
],
},
{
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { diffPayloads, summarizeDiff } from "@dingze/shared";
describe("payload diff", () => {
it("matches array rows by id, so reordering is not a change", () => {
const a = { rows: [{ id: "r1", name: "甲" }, { id: "r2", name: "乙" }] };
const b = { rows: [{ id: "r2", name: "乙" }, { id: "r1", name: "甲" }] };
expect(diffPayloads(a, b)).toEqual([]);
});
it("reports changed fields by path and whole rows as added or removed", () => {
const a = { title: "旧", rows: [{ id: "r1", name: "甲", value: "1" }, { id: "r2", name: "乙" }] };
const b = { title: "新", rows: [{ id: "r1", name: "甲", value: "2" }, { id: "r3", name: "丙" }] };
const entries = diffPayloads(a, b);
expect(entries.map((e) => [e.path, e.kind])).toEqual([
["title", "changed"],
["rows.r1.value", "changed"],
["rows.r2", "removed"],
["rows.r3", "added"],
]);
expect(summarizeDiff(entries)).toEqual({ added: 1, removed: 1, changed: 2 });
});
it("treats an emptied field as removed and blank-to-blank as unchanged", () => {
expect(diffPayloads({ a: "x", b: "" }, { a: "", b: null })).toEqual([{ path: "a", kind: "removed", before: "x" }]);
});
it("compares arrays without ids as whole values", () => {
expect(diffPayloads({ years: [2025, 2026] }, { years: [2025, 2026, 2027] })).toEqual([
{ path: "years", kind: "changed", before: [2025, 2026], after: [2025, 2026, 2027] },
]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { aiDiffLabel, filterRegistry, registrySheet } from "@/lib/dingze/records";
import type { RegistryRow } from "@/lib/dingze/records-api";
const row = (code: string, patch: Partial<RegistryRow> = {}): RegistryRow => ({
code,
status: "not_started",
stale: false,
currentRev: 0,
lockedRev: null,
updatedAt: null,
versions: { total: 0, ai: 0, enterprise: 0, consultant: 0 },
lastEvent: null,
aiDiff: null,
...patch,
});
describe("artifact registry", () => {
const rows = [row("S1-01", { status: "locked", lockedRev: 3 }), row("S2-04", { status: "in_progress", stale: true }), row("S3-02")];
it("filters by stage, by status and by 上游已变更", () => {
expect(filterRegistry(rows, { stage: "goal", status: "all" }).map((r) => r.code)).toEqual(["S2-04"]);
expect(filterRegistry(rows, { stage: "all", status: "locked" }).map((r) => r.code)).toEqual(["S1-01"]);
expect(filterRegistry(rows, { stage: "all", status: "stale" }).map((r) => r.code)).toEqual(["S2-04"]);
});
it("describes the AI-draft-to-locked difference", () => {
expect(aiDiffLabel(null)).toBe("—");
expect(aiDiffLabel({ aiRev: 1, lockedRev: 4, added: 2, removed: 0, changed: 5 })).toBe("v1→v4 改 5 · 增 2");
expect(aiDiffLabel({ aiRev: 2, lockedRev: 2, added: 0, removed: 0, changed: 0 })).toBe("v2→v2 未改动");
});
it("exports one row per artifact with stage, spec id and status", () => {
const sheet = registrySheet(rows);
expect(sheet.rows[0].slice(0, 5)).toEqual(["定战略责", "S1-01", expect.any(String), "P0", "已定版"]);
expect(sheet.rows[1][5]).toBe("是");
});
});