mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
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:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 使用说明
|
||||
|
||||
自驱战略 · 定三责平台把《自驱战略》书中的三张检查表变成一步一步的在线工作:**定战略责 → 定目标责 → 定行动责**。每一步都有一张成果表,企业填写、咨询师复核、企业确认后定版;下游表格引用上游的定版内容,上游一改,下游会提示“上游已变更”。
|
||||
|
||||
入口:`https://<域名>/x/dingze/`,用分配的账号登录(平台不开放自助注册)。
|
||||
|
||||
---
|
||||
|
||||
## 一、企业成员
|
||||
|
||||
### 1. 进入项目
|
||||
|
||||
登录后进入“我的项目”,点开项目进入**工作台**:三阶段进度、待办和“上游已变更”的成果都在这里。
|
||||
|
||||
项目顶部有四个页签:
|
||||
|
||||
| 页签 | 用途 |
|
||||
|---|---|
|
||||
| 工作台 | 项目概览、待办 |
|
||||
| 咨询工作区 | 逐表填写成果(主要工作区域) |
|
||||
| 成果与定版 | 全部成果的状态、版本、操作记录和版本差异 |
|
||||
| 交付 | 导出正式交付物、各部门绩效计分卡、导出记录 |
|
||||
|
||||
### 2. 在咨询工作区填写成果
|
||||
|
||||
- 左侧是步骤条,按书的顺序排列。前一步“本步完成”后,后一步才解锁;上一阶段 P0 成果全部定版后,下一阶段才开启。
|
||||
- 中间是成果表。很多表有“带入 / 导入”按钮,可以从上游表直接带入,不用重抄。
|
||||
- 下方的校验条会列出必须改的问题(红色)和建议(黄色)。红色问题没改完,不能点“本步完成”。
|
||||
- 右侧是**数字咨询师**:可以就当前这张表提问,让它出修改建议。建议以卡片形式给出,**点“确认写入”才会改动成果**。
|
||||
- 改完记得点“保存”。每次保存都会生成一个新版本。
|
||||
|
||||
### 3. 本步完成 → 复核 → 确认
|
||||
|
||||
1. 企业填写完毕,由企业项目负责人或部门负责人点 **本步完成**。
|
||||
2. 咨询师、企业项目负责人或部门负责人**提交复核**,主咨询师**复核通过**或**退回修改**(退回必须写原因)。
|
||||
3. 复核通过后,**企业项目负责人**点 **确认**,成果**定版**。定版后只读,如需修改,由主咨询师“解锁重开”。
|
||||
|
||||
> 只有企业项目负责人可以确认定版;企业管理员身份本身不带确认权。
|
||||
|
||||
不同意但愿意执行的意见,可以在成果下方“团队异议(反对但承诺执行)”里记录。
|
||||
|
||||
### 4. 年度经营计划书
|
||||
|
||||
定行动责的最后两张是公司级和部门级年度经营计划书。“企业录入”的章节由企业填写;“同源 · 自动汇总”的章节直接引用已定版的成果,不需要也不能重写。
|
||||
|
||||
公司级计划书确认时,它引用的《项目任务书》《计划实施推进表》《项目资源匹配表》必须已复核通过,确认后四张表**一起定版**。
|
||||
|
||||
### 5. 导出
|
||||
|
||||
- 成果页右上角“导出 Excel / 导出 Word”导出最近一次**保存**的版本。
|
||||
- “交付”页导出的是**定版版本**;尚未定版的导出文件会标“草稿”。
|
||||
- 所有导出都记录在“交付 → 导出记录”。
|
||||
|
||||
### 6. 通知
|
||||
|
||||
右上角铃铛显示站内通知:待复核、待确认、退回、定版、重开、上游已变更、加入项目。点通知直达对应成果。
|
||||
|
||||
---
|
||||
|
||||
## 二、咨询师
|
||||
|
||||
- 登录后进入**咨询师工作台**:跨企业汇总待复核、待提交复核、待企业确认、上游已变更和待跟进的成果,可按企业和阶段筛选。
|
||||
- 复核:打开成果 → 查看校验结果和“成果与定版 → 历史 → 版本对比” → 复核通过或退回(写原因)。
|
||||
- 解锁重开:已定版成果需要修改时,由主咨询师“解锁重开”并写明原因;下游表格会标“上游已变更”,企业核对后保存一次即可消除。重开后未改动又定版,下游提示会自动消失。
|
||||
- 开例外:确有需要跳过解锁顺序时,主咨询师可为某一步“开例外”,操作会写入审计记录。
|
||||
- **成果与定版**页的“AI 初稿 → 定版”一列,显示数字咨询师给出的最后一版初稿与最终定版之间的差异,用于方法和规则改进。
|
||||
|
||||
---
|
||||
|
||||
## 三、运营管理员 / 咨询管理员
|
||||
|
||||
侧栏“运营管理”:
|
||||
|
||||
| 页面 | 用途 |
|
||||
|---|---|
|
||||
| 企业与开通 | 开通企业(自动建空间和企业管理员账号)、停用 / 启用企业、维护企业成员和组织部门、新建项目、分派项目成员与项目角色 |
|
||||
| 咨询团队 | 创建咨询师、咨询管理员、运营管理员账号,重置密码 |
|
||||
| 方法与规则包 | 成果目录与数字咨询师(只读) |
|
||||
|
||||
要点:
|
||||
|
||||
- 一个企业对应一个独立空间,企业之间的项目、成果、通知、导出互不可见。
|
||||
- 停用企业后,该企业成员立即无法打开项目(运营与咨询管理员仍可查看)。
|
||||
- 新账号的初始密码只在创建时显示一次,请通过安全渠道转交,并提醒首次登录后修改。
|
||||
- 企业管理员可在“本企业”里维护本企业成员和组织部门。
|
||||
|
||||
---
|
||||
|
||||
## 四、角色速查
|
||||
|
||||
| 项目角色 | 能做什么 |
|
||||
|---|---|
|
||||
| 企业项目负责人 | 填写、本步完成、提交复核、确认定版、待确认时退回 |
|
||||
| 部门负责人 | 填写、本步完成、提交复核 |
|
||||
| 项目成员 | 填写 |
|
||||
| 只读成员 | 查看 |
|
||||
| 主咨询师 | 修订、提交复核、复核通过 / 退回、解锁重开、开例外 |
|
||||
| 协作咨询师 | 修订、提交复核 |
|
||||
| 咨询管理员(系统角色) | 解锁重开、开例外、强制定版、强制退回(均需写原因) |
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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("已导出最近一次保存的版本,未保存的修改不在文件里");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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("是");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user