Merge remote-tracking branch 'origin/main' into claude/project-thread-liggvu

This commit is contained in:
Claude
2026-11-02 12:14:13 +00:00
49 changed files with 1523 additions and 146 deletions
+3 -1
View File
@@ -5,7 +5,9 @@
| 读者 | 文档 | 内容 |
|---|---|---|
| 所有人 | [交付总览](delivery-summary.md) | 做了什么、范围、里程碑、验收结论、待办与待决事项 |
| 企业 / 咨询师 / 运营与管理 | [操作手册](user-guide.md) | 按角色分章:企业用户(含企业管理员)、咨询师、运营与管理;附权限速查、状态文字、快捷键、通知 |
| 客户 | [三分钟上手](quick-start.md) | 注册 → 上传资料 → 战略解码 → 目标分解 → 计划制定,五步一页纸;门户“使用指南”同一份内容 |
| 运营 / 咨询师 | [运营与咨询师速查](ops-quick-ref.md) | 新客户、复核、解锁重开、客户常问,一页纸 |
| 查细节 | [详细参考](user-guide.md) | 原操作手册,按角色分章,附权限速查、状态文字、快捷键、通知 |
| 演示 / 验收 | [演示账号](demo-accounts.md) | 两家虚构企业、三个项目、各角色账号、能演示的状态与推荐顺序(密码不入库) |
| 咨询师 / 方法论方 | [成果目录与书稿对照](artifact-catalog.md) | 附录 B 24 项成果的字段、校验规则、上游依赖、导出和书中出处 |
| 开发 / 运维 | [系统架构](architecture.md) | 技术栈、插件与门户结构、数据模型、接口、AI 员工、权限与隔离 |
+19
View File
@@ -7,6 +7,25 @@
- 标“注意”的条目需要运维、咨询师或企业用户知道。
- 逐条验证证据见[验收记录](acceptance.md),后续计划见[路线图](roadmap.md)。
## 2026-11-02 · 插件 0.33.0 · 试用版改为客户全流程
甲方反馈:试用版应该是客户从接触网站到完成任务的完整流程,两家演示企业只是案例;手册要简洁。
- **首页和免费试用**:没登录时打开网站先到首页(五步流程、免费试用、登录)。「免费试用」填企业名称、姓名、手机号、密码,勾选试用说明,就开好试用企业、企业管理员账号(手机号登录)和一个自助项目,自动登录进第 1 步“上传资料”。
- 新接口 `dingze:trialSignUp`(公开,按 IP 每小时 5 个、全站每天 200 个,带蜜罐字段);`dz_enterprises` 加 `trial`;运营和咨询管理员自动加入新企业空间,并收到站内通知。
- 登录页加“还没有账号?免费试用”。邀请码申请照旧保留。
- **项目里按流程走**:顶栏改为 总览 · ① 资料 · ② 战略解码 · ③ 目标分解 · ④ 计划制定 · ⑤ 成果,做完的打勾、没开启的带锁;三个阶段各跳到本阶段第一张没定版的表。原“待办”改为“总览”,最上面是五步流程条和下一步按钮;资料没准备、也还没开始填表时,第一个按钮是“上传资料”。
- **阶段名**:界面上用 战略解码 / 目标分解 / 计划制定,书里的“定战略责 / 定目标责 / 定行动责”作小字保留。导出、计划书和数字咨询师的用语不变。
- **示例项目**:左侧菜单「示例项目」只读展示虚构企业瀚川燃气 2026 的全套表(取自演示数据种子);每张表的工具栏有「看示例」,新窗口打开同一张表对照着填。演示企业的账号和数据不变。
- **批量进人**(第二轮反馈):邀请码加「免审核」(默认开),凭码注册马上开通并进项目,注册页直接登录;训练营批次页「一键发团队码」给每家企业一个免审核码(项目成员 · 20 人 · 60 天),可复制全部转给企业;新接口 `dingze:campInvites`,`dz_invites.autoApprove`。
- **上传资料放到最前**(第二轮反馈):资料页页签改为 ① 上传资料 · ② 企业画像 · ③ 组织与职责,默认打开上传;没有资料时整块是一个大的上传按钮。
- **空表起草**(第二轮反馈):新表打开时顶部提示「按资料起草这张表」,一键让数字咨询师出整张表的修改卡;旁边是「看示例」。
- **首次指引**:企业侧第一次进新项目弹四步操作指引(上传资料 → 战略解码 → 目标分解 → 计划制定,一步一页),关掉后本机不再弹;总览“看操作指引”随时重开。
- **手册**:新增客户版[三分钟上手](quick-start.md)(门户左侧「使用指南」同一份)和我方版[运营与咨询师速查](ops-quick-ref.md);原操作手册改名“详细参考”,只作查阅。
- 注意:
- 开放公开注册前要在 dev 上把注册 → 上传资料 → 第一张表走通一遍(多空间插件在未登录请求下建空间和账号的行为需实测)。
- 准生产上如果开着密码策略,试用注册的密码规则要和它一致(现在是至少 8 位、字母加数字)。
## v0.31.0-beta.1(2026-10-16,beta 标记)
在提交 `v0.31.0-beta.1` 上打了 git tag,作为准生产发布的基线:插件 `@ziqu/plugin-dingze` 0.31.0 + 门户 `dingze` 当前代码。主要内容是甲方试用期间的几批迭代(见下方 10-14 至 10-16 各节):
+1 -1
View File
@@ -9,7 +9,7 @@
| 项目 | 说明 |
|---|---|
| 地址 | https://ziqu.aihupo.cn/x/dingze/ |
| 登录 | 账号 + 密码。不开放公开注册,没有钉钉登录 |
| 登录 | 账号 + 密码。客户可在首页「免费试用」自助注册(2026-11 起,开通前需在 dev 实测);没有钉钉登录 |
| 浏览器 | Chrome 或 Edge 最新版,屏幕宽度 1366 以上;手机只适合查看 |
| 数据 | 两家虚构企业的演示数据:瀚川燃气(城市燃气,大型)、启衡能源(储能与充换电,中小企业),另有三家训练营企业 |
| 数字咨询师 | 接 DeepSeek(Flash)。回复一般 10~30 秒;只给修改建议卡,点“确认”后才写入 |
+42
View File
@@ -0,0 +1,42 @@
# 运营与咨询师速查(我方版)
一页纸,够日常用。细节查[详细参考](user-guide.md)。
## 每天看一眼
- 「运营管理 › 运营看板」:哪些项目卡住了。
- 右上角铃铛:新试用企业注册、待复核的表。
## 新客户
- **客户自己注册的**:在「企业与开通」里带「自助试用」标签。系统已开好企业、管理员账号(手机号登录)和一个自助项目,不用我们做什么。
- **要转陪跑**:打开企业 ›「项目」,编辑项目,服务方式改成「陪跑」,分派主咨询师。
- **乱填或可疑的注册**:打开企业,点「停用企业」。
- **正式客户由我们开**:「企业与开通 › 开通企业」,填企业、管理员和项目。初始密码只显示一次,单独发给客户,不要发群里。
- **训练营**:用「训练营批次」批量开企业,再点「一键发团队码」。每家企业得到一个码(20 人、60 天),转给企业对接人发给参训人员,凭码注册直接进项目,不用审核。
- **企业内训几十人**:在企业页「邀请注册」新建一个邀请码(默认免审核,名额填够),发给企业对接人即可。想把关就把「免审核」勾掉。
## 复核一张表(陪跑项目)
1. 打开「咨询师工作台」,点待复核的表。
2. 看校验条有没有红色问题,看批注。
3. 没问题点「复核通过」;有问题点「退回修改」,写清要改什么。
## 已定版的表要改
- 主咨询师或企业项目负责人点「解锁重开」,写原因。
- 下游的表会提示「上游已变更」,由企业核对后点「已核对」。
## 客户常问
| 问题 | 怎么答 |
|---|---|
| 忘记密码 | 「企业与开通」里找到人,点「重置密码」。新密码只显示一次,单独发给他。 |
| 表打不开 | 上一张还没点「本步完成」。 |
| 不知道怎么填 | 点表上的「看示例」,或左侧菜单「示例项目」;也可以直接问数字咨询师。 |
| 能不能传真实资料 | 试用期请用脱敏资料。资料和对话会发给大模型服务。 |
| 数字咨询师改了我的表? | 不会。它只给修改卡,客户点确认才写入。 |
## 给客户的说明
直接发[三分钟上手](quick-start.md),或让客户看门户左侧菜单的「使用指南」。
+46
View File
@@ -0,0 +1,46 @@
# 三分钟上手(客户版)
做完下面五步,你会拿到公司和部门两级的年度经营计划书。每一步右边都有数字咨询师,不会填就问它。
网址:https://ziqu.aihupo.cn/x/dingze/
## 1. 注册
- 打开网站,点「免费试用」。
- 填企业名称、姓名、手机号、密码,勾选试用说明,点「开始试用」。
- 系统自动登录,进入你的项目。以后用手机号登录。
- 要叫同事一起做:左侧「本企业 › 邀请注册」点「新建邀请码」,把链接发给同事,他们凭码注册就直接进项目。拿到邀请码的同事直接打开链接注册即可。
## 2. 上传资料
- 点顶栏「资料」,上传企业简介、组织架构、上年经营复盘。Word、PDF、Excel 都可以。
- 在「组织与职责」里填好部门和负责人。
- 点右边对话里的「从资料整理企业画像」,看一遍,点确认。
- 试用期间请用脱敏资料。资料不全也可以先往下走。
## 3. 战略解码
- 点顶栏「战略解码」,从第一张表开始填。
- 想看别人怎么填,点表上的「看示例」。
- 数字咨询师给的是修改卡,你点「确认」才会写进表里。记得保存。
- 填完点「本步完成」,再做「自查复核」,最后「确认定版」。下一张表会自动打开。
## 4. 目标分解
- 战略解码的必做表都定版后开启。
- 定年度目标,找到实现路径,把关键项目和指标分到部门。
## 5. 计划制定
- 目标分解定版后开启。
- 写项目任务书、推进表和资源表,最后生成公司和部门的年度经营计划书。
- 在顶栏「成果」里下载 Word 和 Excel。
## 常见问题
- **表上有锁、打不开**:上一张还没完成。先把上一张点「本步完成」。
- **提示「上游已变更」**:前面的表改过了。点开看改了什么,跟着改好,再点「已核对」。
- **数字咨询师回得慢**:一般要 10~30 秒,稍等一下。
- **想请人工咨询师陪跑,或忘记密码**:联系我们。
> 门户里左侧菜单的「使用指南」是同一份内容。改这里时同步改 `src/lib/dingze/guide.ts`。
+4 -2
View File
@@ -1,4 +1,6 @@
# 操作手册
# 详细参考(原操作手册)
> 日常用这两份一页纸就够:客户看[三分钟上手](quick-start.md),我们看[运营与咨询师速查](ops-quick-ref.md)。本文留作查细节,不再整份发给客户。
「自驱战略 · 定三责」平台把《自驱战略落地》书中的三张管理检查表变成一步一步的在线工作:**定战略责 → 定目标责 → 定行动责**。每一步有一张成果表,企业填写、咨询师复核、企业确认后定版。下游表引用上游的定版内容;上游一改,下游会提示“上游已变更”。
@@ -72,7 +74,7 @@
### 6. 登录与密码
- 平台**不开放公开注册**。账号由运营或本企业管理员开通,也可以凭邀请码申请(见第二章第 1 节)。
- 客户可以在首页点「免费试用」自助注册,自动开好试用企业和自助项目。其他账号由运营或本企业管理员开通,也可以凭邀请码申请(见第二章第 1 节)。
- 忘记密码:点登录页的“忘记密码”,按提示联系本企业管理员或运营重置。
- 打不开某个项目时,页面上有“回我的项目”。
+1 -1
View File
@@ -4,7 +4,7 @@
"displayName.zh-CN": "定责 · 企业战略咨询平台",
"description": "Responsibility stages (strategy, goals, actions) for the strategy consulting platform: data model, lifecycle actions and digital consultants.",
"description.zh-CN": "企业战略咨询平台“定三责”业务插件:数据模型、成果流转接口与数字咨询师。",
"version": "0.32.2",
"version": "0.33.0",
"main": "dist/server/index.js",
"dependencies": {},
"peerDependencies": {
@@ -14,6 +14,8 @@ export default defineCollection({
{ type: 'string', name: 'status', defaultValue: 'active' },
// 运营打开后,企业管理员可以自建项目(企业内训 · 自助)。
{ type: 'boolean', name: 'allowSelfProject', defaultValue: false },
// 客户在首页自助注册的试用企业(2026-11)。
{ type: 'boolean', name: 'trial', defaultValue: false },
{ type: 'hasMany', name: 'projects', target: 'dz_projects', foreignKey: 'enterpriseId' },
{ type: 'hasMany', name: 'orgUnits', target: 'dz_org_units', foreignKey: 'enterpriseId' },
spaceField,
@@ -20,6 +20,8 @@ export default defineCollection({
// active 可用 / disabled 已停用
{ type: 'string', name: 'status', defaultValue: 'active' },
{ type: 'string', name: 'note' },
// 免审核:凭码注册直接开通账号并加入项目(训练营、企业内训批量进人用)。
{ type: 'boolean', name: 'autoApprove', defaultValue: false },
spaceField,
],
});
@@ -22,7 +22,8 @@ import { consultantBoard } from './services/board';
import { exportSnapshot, importSnapshot } from './services/snapshot';
import { opsBoard } from './services/usage';
import { checkInvite, listInvites, registerWithInvite, registrationStatus, reviewRegistration, saveInvite } from './services/invites';
import { campAccounts, campAttach, campClose, campDetail, campEnroll, campRemind, listCamps, saveCamp } from './services/camps';
import { trialSignUp } from './services/trial';
import { campAccounts, campAttach, campClose, campDetail, campEnroll, campInvites, campRemind, listCamps, saveCamp } from './services/camps';
import { mergeGoalTargets } from './upgrade/merge-goal-targets';
import { addGuidanceGap, listGuidanceGaps, reviewGuidanceGap } from './services/guidance';
import { actOnExpertRequest, createExpertRequest, expertRequestDetail, listExpertRequests } from './services/expert';
@@ -118,9 +119,11 @@ export class PluginDingzeServer extends Plugin {
campAttach: action((ctx) => campAttach(ctx, values(ctx) as any)),
campAccounts: action((ctx) => campAccounts(ctx, values(ctx) as any)),
campRemind: action((ctx) => campRemind(ctx, values(ctx) as any)),
campInvites: action((ctx) => campInvites(ctx, values(ctx) as any)),
campClose: action((ctx) => campClose(ctx, values(ctx) as any)),
checkInvite: action((ctx) => checkInvite(ctx, ctx.action.params as any)),
registerWithInvite: action((ctx) => registerWithInvite(ctx, values(ctx))),
trialSignUp: action((ctx) => trialSignUp(ctx, values(ctx))),
registrationStatus: action((ctx) => registrationStatus(ctx, ctx.action.params as any)),
listInvites: action((ctx) => listInvites(ctx, ctx.action.params.enterpriseId)),
saveInvite: action((ctx) => saveInvite(ctx, values(ctx) as any)),
@@ -280,8 +283,8 @@ export class PluginDingzeServer extends Plugin {
});
// Every handler checks space membership, project role and artifact status itself.
this.app.acl.allow('dingze', '*', 'loggedIn');
// 邀请码注册:只有这三个动作对未登录的人开放,各自限流。
this.app.acl.allow('dingze', ['checkInvite', 'registerWithInvite', 'registrationStatus'], 'public');
// 邀请码注册和试用注册:只有这四个动作对未登录的人开放,各自限流。
this.app.acl.allow('dingze', ['checkInvite', 'registerWithInvite', 'registrationStatus', 'trialSignUp'], 'public');
// Originals are uploaded with the standard multipart create; nothing else on the
// collection is open, reads go through dingze:downloadMaterial.
this.app.acl.allow('dz_material_files', 'create', 'loggedIn');
@@ -5,6 +5,7 @@ import { HttpError, SYSTEM_ROLES, requireOps, systemAccess, userSpaceNames } fro
import { openCommentCounts } from './comments';
import { createProject, provisionEnterprise, setProjectMembers } from './enterprises';
import { notify } from './notify';
import { ensureTeamInvite } from './invites';
import { createUser, creator } from './ops';
import { usageByProject } from './usage';
@@ -280,3 +281,24 @@ export async function campClose(ctx: Context, input: { campId: number; projects:
await camp.update({ status: 'closed' });
return { updated };
}
/** 一键发团队码:批次里每个项目一个免审核邀请码,企业转发给自己的人,凭码注册即加入项目。 */
export async function campInvites(ctx: Context, input: { campId: number; projectIds?: number[] }) {
const camp = await loadCamp(ctx, input.campId);
const projects = await campProjects(ctx, camp.get('id'));
const only = Array.isArray(input.projectIds) && input.projectIds.length ? new Set(input.projectIds.map(Number)) : null;
const results = [];
for (const p of projects as any[]) {
if (only && !only.has(p.get('id'))) continue;
const { invite, created } = await ensureTeamInvite(ctx, p, { maxUses: 20, days: 60 });
results.push({
projectId: p.get('id'),
enterprise: p.get('enterprise')?.get('shortName') || p.get('enterprise')?.get('name'),
code: invite.get('code'),
maxUses: invite.get('maxUses'),
expiresAt: invite.get('expiresAt'),
created,
});
}
return { results };
}
@@ -17,7 +17,7 @@ const USERNAME = /^[A-Za-z0-9_.@-]{3,50}$/;
// ── 限流:公开接口按来源 IP 和邀请码计数(进程内,够挡住撞码和刷注册)。训练营现场几十人常共用一个
// 出口 IP,所以按 IP 的上限放得比较宽;码本身 8 位、约 8.5×10¹¹ 种,撞码靠的是查询上限。 ──
const hits = new Map<string, number[]>();
function limit(key: string, max: number, windowMs: number) {
export function limit(key: string, max: number, windowMs: number) {
const now = Date.now();
const list = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (list.length >= max) throw new HttpError(429, '操作太频繁,请稍后再试');
@@ -25,7 +25,7 @@ function limit(key: string, max: number, windowMs: number) {
hits.set(key, list);
if (hits.size > 5000) for (const [k, v] of hits) if (!v.some((t) => now - t < windowMs)) hits.delete(k);
}
const ip = (ctx: Context) => String((ctx as any).request?.ip ?? (ctx as any).ip ?? 'unknown');
export const ip = (ctx: Context) => String((ctx as any).request?.ip ?? (ctx as any).ip ?? 'unknown');
function newCode(): string {
const bytes = crypto.randomBytes(8);
@@ -55,9 +55,38 @@ function describe(invite: any) {
project: invite.get('project')?.get('name') ?? null,
role: role ? PROJECT_ROLE_LABELS[role] : null,
dept: invite.get('orgUnit')?.get('name') ?? null,
autoApprove: !!invite.get('autoApprove'),
};
}
/** 开通账号并按码加入项目:审核通过时用,免审核的码在注册时直接用。 */
async function activate(ctx: Context, invite: any, reg: any, spaceName: string) {
const auto = !!invite?.get('autoApprove');
const userId = await ctx.db.sequelize.transaction(async (transaction: any) => {
const created = await insertUser(
ctx,
{ username: reg.get('username'), nickname: reg.get('nickname'), email: reg.get('email') ?? undefined, phone: reg.get('phone') ?? undefined, systemRole: SYSTEM_ROLES.entMember },
spaceName,
transaction,
);
// 账号用申请人自己设的密码:写入注册时存的哈希,跳过密码字段的二次哈希。
await ctx.db.getRepository('users').model.update({ password: reg.get('passwordHash') }, { where: { id: created.user.id }, hooks: false, transaction });
await reg.update({ status: 'approved', userId: created.user.id, passwordHash: null, reviewedById: auto ? null : ctx.auth?.user?.id, reviewedAt: new Date() }, { transaction });
await audit(ctx, spaceName, auto ? 'registration.auto' : 'registration.approve', { registrationId: reg.get('id'), userId: created.user.id, username: reg.get('username') }, transaction);
return created.user.id as number;
});
if (invite?.get('projectId')) {
const projectId = invite.get('projectId') as number;
const projectRole = (invite.get('projectRole') ?? 'member') as ProjectRole;
const repo = ctx.db.getRepository('dz_project_members');
const by = auto ? { createdById: userId, updatedById: userId } : creator(ctx);
if (!(await repo.findOne({ filter: { projectId, userId } })))
await repo.create({ values: { projectId, userId, projectRole, orgUnitId: invite.get('orgUnitId') ?? null, spaceName, ...by } });
await notify(ctx, { projectId, spaceName, userIds: [userId], kind: 'assigned', title: '你已加入项目', content: `角色:${PROJECT_ROLE_LABELS[projectRole]}`, link: `/projects/${projectId}` });
}
return userId;
}
// ── 公开接口 ─────────────────────────────────────────────────────────
export async function checkInvite(ctx: Context, params: { code?: unknown }) {
@@ -109,6 +138,13 @@ export async function registerWithInvite(
},
});
const scope = describe(invite);
// 免审核的码:马上开通,注册页直接登录进项目。
if (invite.get('autoApprove')) {
await activate(ctx, invite, reg, spaceName);
return { status: 'approved', enterprise: scope.enterprise, projectId: (invite.get('projectId') ?? null) as number | null };
}
// 通知本企业的企业管理员(没有企业管理员时通知空间里的运营)。
const members = await ctx.db.getRepository('spaces.users', spaceName).find({ appends: ['roles'] });
const has = (u: any, role: string) => (u.get('roles') ?? []).some((r: any) => r.get('name') === role);
@@ -119,7 +155,6 @@ export async function registerWithInvite(
userIds: members.filter((u: any) => has(u, SYSTEM_ROLES.ops) || has(u, SYSTEM_ROLES.consultAdmin)).map((u: any) => u.get('id')),
link: `/ops/enterprises/${invite.get('enterpriseId')}?tab=invites`,
};
const scope = describe(invite);
await notify(ctx, {
projectId: (invite.get('projectId') ?? null) as number,
spaceName,
@@ -129,7 +164,7 @@ export async function registerWithInvite(
content: [`登录名 ${username}`, scope.project && `项目 ${scope.project}`, scope.role, scope.dept, reg.get('remark') && `自述:${reg.get('remark')}`].filter(Boolean).join(' · '),
link: reviewers.link,
});
return { status: 'pending', enterprise: scope.enterprise };
return { status: 'pending', enterprise: scope.enterprise, projectId: null };
}
/** 门户登录前用它告诉申请人“还在审核 / 没通过”。只回状态,不回其他信息;账号已存在时回 none。 */
@@ -170,6 +205,7 @@ export async function listInvites(ctx: Context, enterpriseId: unknown) {
expiresAt: i.get('expiresAt'),
status: i.get('status'),
note: i.get('note'),
autoApprove: !!i.get('autoApprove'),
createdBy: i.get('createdBy')?.get('nickname') ?? null,
createdAt: i.get('createdAt'),
})),
@@ -192,7 +228,7 @@ export async function listInvites(ctx: Context, enterpriseId: unknown) {
export async function saveInvite(
ctx: Context,
input: { enterpriseId: number; id?: number; projectId?: number | null; projectRole?: string | null; orgUnitId?: number | null; maxUses?: number; expiresAt?: string | null; status?: string; note?: string },
input: { enterpriseId: number; id?: number; projectId?: number | null; projectRole?: string | null; orgUnitId?: number | null; maxUses?: number; expiresAt?: string | null; status?: string; note?: string; autoApprove?: boolean },
) {
const scope = await loadEnterpriseScope(ctx, input.enterpriseId, true);
const enterpriseId = scope.enterprise.get('id');
@@ -223,6 +259,7 @@ export async function saveInvite(
expiresAt,
status: input.status === 'disabled' ? 'disabled' : 'active',
note: String(input.note ?? '').trim().slice(0, 100) || null,
autoApprove: input.autoApprove === true,
};
const repo = ctx.db.getRepository('dz_invites');
if (input.id) {
@@ -235,7 +272,7 @@ export async function saveInvite(
let code = newCode();
while (await repo.findOne({ filter: { code } })) code = newCode();
const invite = await repo.create({ values: { ...values, code, enterpriseId, spaceName: scope.spaceName, ...creator(ctx) } });
await audit(ctx, scope.spaceName, 'invite.create', { inviteId: invite.get('id'), projectId, projectRole: values.projectRole, maxUses });
await audit(ctx, scope.spaceName, 'invite.create', { inviteId: invite.get('id'), projectId, projectRole: values.projectRole, maxUses, autoApprove: values.autoApprove });
return invite.toJSON();
}
@@ -254,27 +291,43 @@ export async function reviewRegistration(ctx: Context, input: { enterpriseId: nu
return { status: 'rejected' };
}
const userId = await ctx.db.sequelize.transaction(async (transaction: any) => {
const created = await insertUser(
ctx,
{ username: reg.get('username'), nickname: reg.get('nickname'), email: reg.get('email') ?? undefined, phone: reg.get('phone') ?? undefined, systemRole: SYSTEM_ROLES.entMember },
scope.spaceName,
transaction,
);
// 账号用申请人自己设的密码:写入注册时存的哈希,跳过密码字段的二次哈希。
await ctx.db.getRepository('users').model.update({ password: reg.get('passwordHash') }, { where: { id: created.user.id }, hooks: false, transaction });
await reg.update({ status: 'approved', userId: created.user.id, passwordHash: null, ...reviewer }, { transaction });
await audit(ctx, scope.spaceName, 'registration.approve', { registrationId: reg.get('id'), userId: created.user.id, username: reg.get('username') }, transaction);
return created.user.id;
});
const invite = reg.get('invite');
if (invite?.get('projectId')) {
const projectId = invite.get('projectId') as number;
const projectRole = (invite.get('projectRole') ?? 'member') as ProjectRole;
const repo = ctx.db.getRepository('dz_project_members');
if (!(await repo.findOne({ filter: { projectId, userId } })))
await repo.create({ values: { projectId, userId, projectRole, orgUnitId: invite.get('orgUnitId') ?? null, spaceName: scope.spaceName, ...creator(ctx) } });
await notify(ctx, { projectId, spaceName: scope.spaceName, userIds: [userId], kind: 'assigned', title: '你已加入项目', content: `角色:${PROJECT_ROLE_LABELS[projectRole]}`, link: `/projects/${projectId}` });
}
const userId = await activate(ctx, reg.get('invite'), reg, scope.spaceName);
return { status: 'approved', userId };
}
/**
* 团队码:给一个项目一个免审核的邀请码(项目成员 · 20 人 · 60 天),已有可用的就复用。
* 训练营“一键发团队码”和企业自助发码都用它。
*/
export async function ensureTeamInvite(ctx: Context, project: any, opts: { maxUses?: number; days?: number } = {}) {
const repo = ctx.db.getRepository('dz_invites');
const projectId = project.get('id') as number;
const existing = await repo.findOne({
filter: { projectId, status: 'active', autoApprove: true, projectRole: 'member', orgUnitId: null },
sort: ['-createdAt'],
});
if (existing && (!existing.get('expiresAt') || new Date(existing.get('expiresAt')).getTime() > Date.now())) {
const used = await usedCount(ctx, existing.get('id'));
if (used < Number(existing.get('maxUses') ?? 0)) return { invite: existing, created: false };
}
let code = newCode();
while (await repo.findOne({ filter: { code } })) code = newCode();
const invite = await repo.create({
values: {
code,
enterpriseId: project.get('enterpriseId'),
projectId,
projectRole: 'member',
orgUnitId: null,
maxUses: Math.min(Math.max(Number(opts.maxUses) || 20, 1), 500),
expiresAt: new Date(Date.now() + (opts.days ?? 60) * 86400_000),
status: 'active',
autoApprove: true,
note: '团队码',
spaceName: project.get('spaceName'),
...creator(ctx),
},
});
await audit(ctx, project.get('spaceName'), 'invite.create', { inviteId: invite.get('id'), projectId, projectRole: 'member', maxUses: invite.get('maxUses'), autoApprove: true, team: true });
return { invite, created: true };
}
@@ -0,0 +1,119 @@
import { randomBytes } from 'node:crypto';
import type { Context } from '@nocobase/actions';
import { HttpError, SYSTEM_ROLES } from './access';
import { ip, limit } from './invites';
import { notify } from './notify';
// 公开试用注册(2026-11 甲方反馈:试用版要打通客户全流程,客户自己注册就能用)。
// 一次提交开好:企业空间 + 试用企业(trial)+ 企业管理员账号(手机号作登录名,密码由本人设)
// + 一个自助项目(企业内训 · 自助,本人为企业项目负责人)。运营和咨询管理员自动加入该空间,
// 在“企业与开通”里能看到、停用,或改成陪跑并分派咨询师。逐表解锁、定版门禁、空间隔离都不变。
const PHONE = /^1\d{10}$/;
export interface TrialInput {
enterpriseName?: unknown;
nickname?: unknown;
phone?: unknown;
password?: unknown;
/** 勾选“试用期用脱敏资料,资料和对话会发往大模型服务”。 */
agree?: unknown;
/** 蜜罐字段:页面上隐藏,人不会填。 */
website?: unknown;
}
export function checkTrialInput(input: TrialInput) {
if (input.website) throw new HttpError(400, '提交内容不正确');
const enterpriseName = String(input.enterpriseName ?? '').trim();
const nickname = String(input.nickname ?? '').trim();
const phone = String(input.phone ?? '').trim();
const password = String(input.password ?? '');
if (enterpriseName.length < 2 || enterpriseName.length > 60) throw new HttpError(400, '请填写企业名称(2~60 个字)');
if (!nickname || nickname.length > 50) throw new HttpError(400, '请填写你的姓名');
if (!PHONE.test(phone)) throw new HttpError(400, '请填写 11 位手机号');
if (password.length < 8 || password.length > 64 || !/[A-Za-z]/.test(password) || !/\d/.test(password)) {
throw new HttpError(400, '密码至少 8 位,并同时包含字母和数字');
}
if (input.agree !== true) throw new HttpError(400, '请先勾选同意试用说明');
return { enterpriseName, nickname, phone, password };
}
export async function trialSignUp(ctx: Context, input: TrialInput) {
// 同一出口 IP 每小时 5 个、全站每天 200 个,挡住脚本刷号。
limit(`trial:${ip(ctx)}`, 5, 60 * 60_000);
const { enterpriseName, nickname, phone, password } = checkTrialInput(input);
limit('trial:all', 200, 24 * 60 * 60_000);
const users = ctx.db.getRepository('users');
if (await users.findOne({ filter: { $or: [{ username: phone }, { phone }] } })) {
throw new HttpError(409, '这个手机号已经注册过,请直接登录', 'PHONE_TAKEN');
}
const staff = await users.find({
filter: { 'roles.name': { $in: [SYSTEM_ROLES.ops, SYSTEM_ROLES.consultAdmin] } },
fields: ['id'],
});
const staffIds = staff.map((u: any) => u.get('id') as number);
const spaceName = `dz-${randomBytes(5).toString('hex')}`;
const shortName = enterpriseName.slice(0, 20);
const year = new Date().getFullYear();
const created = await ctx.db.sequelize.transaction(async (transaction: any) => {
await ctx.db.getRepository('spaces').create({ values: { name: spaceName, title: shortName }, transaction });
const user = await users.create({
values: { username: phone, nickname, phone, password, roles: [SYSTEM_ROLES.entAdmin] },
// Password-policy and multi-space hooks on users read the request context.
context: ctx,
transaction,
});
const userId = user.get('id') as number;
const by = { createdById: userId, updatedById: userId };
await ctx.db.getRepository('spaces.users', spaceName).add({ tk: [...new Set([userId, ...staffIds])], transaction });
const enterprise = await ctx.db.getRepository('dz_enterprises').create({
values: { name: enterpriseName, shortName, size: 'sme', status: 'active', trial: true, allowSelfProject: true, spaceName, ...by },
transaction,
});
const project = await ctx.db.getRepository('dz_projects').create({
values: {
enterpriseId: enterprise.get('id'),
name: `${year} 年度战略落地`,
year,
scene: 'inhouse',
service: 'self',
primaryExpression: 'sixfold',
keyProjectLevel: 2,
scheduleScale: 'month',
spaceName,
...by,
},
transaction,
});
await ctx.db.getRepository('dz_project_members').create({
values: { projectId: project.get('id'), userId, projectRole: 'ent_lead', spaceName, ...by },
transaction,
});
await ctx.db.getRepository('dz_audit_events').create({
values: {
action: 'trial.signup',
meta: { enterpriseId: enterprise.get('id'), projectId: project.get('id'), userId, actorId: userId },
spaceName,
...by,
},
transaction,
});
return { enterpriseId: enterprise.get('id') as number, projectId: project.get('id') as number };
});
await notify(ctx, {
projectId: created.projectId,
spaceName,
userIds: staffIds,
kind: 'registration',
title: `新试用企业:${enterpriseName}`,
content: `${nickname} · ${phone.slice(0, 3)}****${phone.slice(-4)} 自助注册试用,已开好自助项目`,
link: `/ops/enterprises/${created.enterpriseId}`,
}).catch((error: any) => ctx.app.logger.warn(`dingze trial notify failed: ${error?.message ?? error}`));
return { username: phone, projectId: created.projectId };
}
@@ -59,10 +59,11 @@ export interface DerivedDef {
kind: 'view' | 'embedded' | 'snapshot';
}
export const STAGES: { key: StageKey; name: string; goal: string }[] = [
{ key: 'strategy', name: '定战略责', goal: '清晰共识' },
{ key: 'goal', name: '定目标责', goal: '纵横贯通' },
{ key: 'action', name: '定行动责', goal: '可视可控' },
// name 是书里的说法(定三责),flow 是界面上给客户看的流程名(2026-11 甲方反馈:按客户流程叫)。
export const STAGES: { key: StageKey; name: string; flow: string; goal: string }[] = [
{ key: 'strategy', name: '定战略责', flow: '战略解码', goal: '清晰共识' },
{ key: 'goal', name: '定目标责', flow: '目标分解', goal: '纵横贯通' },
{ key: 'action', name: '定行动责', flow: '计划制定', goal: '可视可控' },
];
export const ARTIFACTS: ArtifactDef[] = [
+13 -2
View File
@@ -1,6 +1,6 @@
import { Authenticated } from "@refinedev/core";
import { CatchAllNavigate } from "@refinedev/react-router";
import { Outlet, Route, Routes } from "react-router";
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router";
import { AclBootstrap } from "@/components/access-control/acl-bootstrap";
import { NavigateToAccessibleResource } from "@/components/access-control/navigate-to-accessible-resource";
@@ -9,12 +9,20 @@ import { Layout } from "@/components/app-shell/layout";
import { ForgotPassword } from "@/pages/forgot-password";
import { Login } from "@/pages/login";
import { InviteRegister } from "@/pages/register/invite";
import { TrialSignUpPage } from "@/pages/trial";
import { WelcomePage } from "@/pages/welcome";
import {
AppExtensionProviders,
configuredRouteElements,
extensionStandaloneRouteElements,
} from "./extensions";
/** 没登录时:打开网站根地址去首页,打开别的页面去登录(登录后回到原页面)。 */
function SignedOutFallback() {
const { pathname } = useLocation();
return pathname === "/" ? <Navigate to="/welcome" replace /> : <CatchAllNavigate to="/login" />;
}
export function AppRoutes() {
return (
<Routes>
@@ -22,7 +30,7 @@ export function AppRoutes() {
element={
<Authenticated
key="authenticated-inner"
fallback={<CatchAllNavigate to="/login" />}
fallback={<SignedOutFallback />}
>
<AclBootstrap>
<AppExtensionProviders>
@@ -59,6 +67,9 @@ export function AppRoutes() {
<Route path="/signin" element={<Login />} />
{/* 公开注册关闭;只能凭邀请码申请,审核通过才开通账号。 */}
<Route path="/register" element={<InviteRegister />} />
{/* 客户从首页开始:看五步流程 → 免费试用注册(自动开通、自动登录)。 */}
<Route path="/welcome" element={<WelcomePage />} />
<Route path="/trial" element={<TrialSignUpPage />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
</Route>
</Routes>
+15 -7
View File
@@ -1,8 +1,16 @@
import type { PropsWithChildren, ReactNode } from "react";
import { STAGES } from "@dingze/shared";
import { Brand } from "@/components/app-shell/brand";
// 客户从注册到拿到计划书的五步(与项目里的流程条一致)。
export const CUSTOMER_FLOW = [
{ name: "注册开通", hint: "填企业名称和手机号,马上开好试用项目" },
{ name: "上传资料", hint: "企业简介、组织架构、上年经营复盘" },
{ name: "战略解码", hint: "把战略说清楚、能衡量" },
{ name: "目标分解", hint: "年度目标找到路径,责任分到部门" },
{ name: "计划制定", hint: "任务、节点、资源落到人,生成年度经营计划书" },
];
type AuthLayoutProps = PropsWithChildren<{
title: string;
description: string;
@@ -41,14 +49,14 @@ export function AuthLayout({
自驱战略 · 定三责
</h2>
<p className="mt-5 max-w-lg text-base leading-7 text-brand-foreground/75">
咨询师与企业团队在同一张表上达成共识:战略说得清,目标对得上,行动落到人。
从注册到拿到年度经营计划书,五步走完。每一步都有数字咨询师陪你填。
</p>
<ol className="mt-10 flex max-w-md flex-col gap-3">
{STAGES.map((stage, index) => (
<ol className="mt-8 flex max-w-md flex-col gap-2.5">
{CUSTOMER_FLOW.map((stage, index) => (
<li
key={stage.key}
className="flex items-center gap-4 rounded-xl border border-white/15 bg-white/5 px-5 py-4"
key={stage.name}
className="flex items-center gap-4 rounded-xl border border-white/15 bg-white/5 px-5 py-3"
>
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-gold font-heading text-lg font-bold text-brand dark:text-brand-foreground">
{index + 1}
@@ -58,7 +66,7 @@ export function AuthLayout({
{stage.name}
</span>
<span className="text-sm text-brand-foreground/70">
{stage.goal}
{stage.hint}
</span>
</span>
</li>
@@ -22,6 +22,8 @@ const publicAuthPaths = new Set([
"/signin",
"/register",
"/forgot-password",
"/welcome",
"/trial",
]);
export function AuthAutoRedirectProvider({
+9 -4
View File
@@ -14,11 +14,16 @@ export function DefaultSignInPage({
return (
<AuthLayout
title="登录"
description="使用咨询机构为你开通的账号登录。"
description="用手机号或开通的账号登录。"
footer={
<Link to="/register" className="text-muted-foreground hover:text-foreground hover:underline hover:underline-offset-4">
有邀请码?申请加入
</Link>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<Link to="/trial" className="font-medium text-primary hover:underline hover:underline-offset-4">
还没有账号?免费试用
</Link>
<Link to="/register" className="text-muted-foreground hover:text-foreground hover:underline hover:underline-offset-4">
有邀请码?申请加入
</Link>
</div>
}
>
<DynamicSignIn renderAuthenticator={renderAuthenticator} />
+87
View File
@@ -0,0 +1,87 @@
import { STAGES, type StageKey } from "@dingze/shared";
import type { StageProgress } from "./progress";
// 客户流程(2026-11 甲方反馈):注册 → 上传资料 → 战略解码 → 目标分解 → 计划制定 → 下载成果。
// 注册在登录前完成;登录后项目里走后面五步。三个阶段的表、解锁和定版规则不变,这里只把它们
// 按客户的顺序排成一条线,告诉他现在在哪一步。
export type FlowKey = "materials" | StageKey | "deliver";
export type FlowState = "done" | "current" | "todo" | "locked";
export type FlowStep = {
key: FlowKey;
/** 1-based number shown to the customer. */
no: number;
label: string;
/** One short line: what this step is for. */
hint: string;
/** The book's name (定战略责 …) for the three stages. */
bookName?: string;
state: FlowState;
/** e.g. "已定版 3 / 7" or "2 份资料 · 画像已确认". */
detail: string;
};
const STAGE_HINTS: Record<StageKey, string> = {
strategy: "把战略说清楚、能衡量",
goal: "定年度目标,找路径,责任分到部门",
action: "落实任务、节点和资源,生成年度经营计划书",
};
export type FlowInput = {
stages: StageProgress[];
/** 资料与画像这一项已就绪(至少一份资料,画像有确认的条目)。 */
materialsReady: boolean;
materialCount: number;
/** 任一张表已经开始填。 */
started: boolean;
};
/**
* The five steps a customer walks through inside a project, and which one they are on.
* 资料可以先跳过:已经开始填表时,“当前”落到第一个没做完的阶段上,资料只标“未完成”。
*/
export function flowSteps({ stages, materialsReady, materialCount, started }: FlowInput): FlowStep[] {
const steps: FlowStep[] = [
{
key: "materials",
no: 1,
label: "上传资料",
hint: "企业简介、组织架构、上年经营复盘",
state: materialsReady ? "done" : "todo",
detail: materialsReady ? `${materialCount} 份资料 · 画像已确认` : materialCount ? `${materialCount} 份资料 · 画像待确认` : "还没有资料",
},
...STAGES.map((stage, i): FlowStep => {
const p = stages.find((s) => s.stage === stage.key);
const state: FlowState = !p ? "locked" : p.complete ? "done" : p.open ? "todo" : "locked";
return {
key: stage.key,
no: i + 2,
label: stage.flow,
bookName: stage.name,
hint: STAGE_HINTS[stage.key],
state,
detail: p ? `必做表已定版 ${p.locked} / ${p.gateTotal}` : "",
};
}),
];
const allLocked = stages.length > 0 && stages.every((s) => s.complete);
steps.push({
key: "deliver",
no: 5,
label: "下载成果",
hint: "计划书 Word、各表 Excel 和图片",
state: allLocked ? "todo" : "locked",
detail: allLocked ? "可以下载定版成果" : "计划制定定版后可下载",
});
// 当前步:资料没好且还没开始填表 → 资料;否则第一个没做完、已开启的步骤。
const current =
!materialsReady && !started
? steps[0]
: steps.find((s) => s.key !== "materials" && s.state === "todo") ?? (!materialsReady ? steps[0] : null);
if (current) current.state = "current";
return steps;
}
+63
View File
@@ -0,0 +1,63 @@
// 客户版使用指南(门户“使用指南”页,与 docs/quick-start.md 同一份内容)。
// 写法:短句,一步一个动作,少用术语。改这里时同步改 docs/quick-start.md。
export type GuideSection = { no?: number; title: string; items: string[] };
export const GUIDE_INTRO = "做完下面五步,你会拿到公司和部门两级的年度经营计划书。每一步右边都有数字咨询师,不会填就问它。";
export const GUIDE_STEPS: GuideSection[] = [
{
no: 1,
title: "注册",
items: [
"打开网站,点「免费试用」。",
"填企业名称、姓名、手机号、密码,勾选试用说明,点「开始试用」。",
"系统自动登录,进入你的项目。以后用手机号登录。",
"要叫同事一起做:左侧「本企业 › 邀请注册」点「新建邀请码」,把链接发给同事,他们凭码注册就直接进项目。拿到邀请码的同事直接打开链接注册即可。",
],
},
{
no: 2,
title: "上传资料",
items: [
"点顶栏「资料」,上传企业简介、组织架构、上年经营复盘。Word、PDF、Excel 都可以。",
"在「组织与职责」里填好部门和负责人。",
"点右边对话里的「从资料整理企业画像」,看一遍,点确认。",
"试用期间请用脱敏资料。资料不全也可以先往下走。",
],
},
{
no: 3,
title: "战略解码",
items: [
"点顶栏「战略解码」,从第一张表开始填。",
"想看别人怎么填,点表上的「看示例」。",
"数字咨询师给的是修改卡,你点「确认」才会写进表里。记得保存。",
"填完点「本步完成」,再做「自查复核」,最后「确认定版」。下一张表会自动打开。",
],
},
{
no: 4,
title: "目标分解",
items: ["战略解码的必做表都定版后开启。", "定年度目标,找到实现路径,把关键项目和指标分到部门。"],
},
{
no: 5,
title: "计划制定",
items: [
"目标分解定版后开启。",
"写项目任务书、推进表和资源表,最后生成公司和部门的年度经营计划书。",
"在顶栏「成果」里下载 Word 和 Excel。",
],
},
];
export const GUIDE_FAQ: GuideSection = {
title: "常见问题",
items: [
"表上有锁、打不开:上一张还没完成。先把上一张点「本步完成」。",
"提示「上游已变更」:前面的表改过了。点开看改了什么,跟着改好,再点「已核对」。",
"数字咨询师回得慢:一般要 10~30 秒,稍等一下。",
"想请人工咨询师陪跑,或忘记密码:联系我们。",
],
};
+11 -2
View File
@@ -6,7 +6,7 @@ import type { ProjectRole } from "@dingze/shared";
// 邀请码注册(审核制)。checkInvite / registerWithInvite / registrationStatus 不需要登录,服务端限流;
// 其余给企业管理员和运营。
export type InviteScope = { enterprise: string; project: string | null; role: string | null; dept: string | null };
export type InviteScope = { enterprise: string; project: string | null; role: string | null; dept: string | null; autoApprove: boolean };
export type Invite = {
id: number;
@@ -21,6 +21,8 @@ export type Invite = {
expiresAt: string | null;
status: "active" | "disabled";
note: string | null;
/** 免审核:凭码注册直接开通并加入项目。 */
autoApprove: boolean;
createdBy: string | null;
createdAt: string;
};
@@ -52,6 +54,7 @@ export type InviteInput = {
expiresAt?: string | null;
status?: "active" | "disabled";
note?: string;
autoApprove?: boolean;
};
const get = <T>(action: string, query: Record<string, string | number | undefined> = {}) =>
@@ -61,7 +64,7 @@ const post = <T>(action: string, body: unknown) => nocobaseClient.action<T>("din
export const inviteApi = {
checkInvite: (code: string) => get<InviteScope>("checkInvite", { code }),
register: (values: { code: string; nickname: string; username: string; password: string; phone?: string; email?: string; remark?: string }) =>
post<{ status: "pending"; enterprise: string }>("registerWithInvite", values),
post<{ status: "pending" | "approved"; enterprise: string; projectId: number | null }>("registerWithInvite", values),
registrationStatus: (account: string) => get<{ status: "pending" | "rejected" | "none" }>("registrationStatus", { account }),
listInvites: (enterpriseId: number) => get<{ invites: Invite[]; registrations: Registration[] }>("listInvites", { enterpriseId }),
saveInvite: (values: InviteInput) => post<Invite>("saveInvite", values),
@@ -82,3 +85,9 @@ export function passwordProblem(password: string): string | null {
if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) return "要同时包含字母和数字";
return null;
}
/** 公开试用注册:一次开好试用企业、企业管理员账号(手机号作登录名)和一个自助项目。 */
export const trialApi = {
signUp: (values: { enterpriseName: string; nickname: string; phone: string; password: string; agree: boolean; website?: string }) =>
post<{ username: string; projectId: number }>("trialSignUp", values),
};
+4
View File
@@ -35,6 +35,8 @@ export type Enterprise = {
createdAt: string;
/** 运营打开后,企业管理员可以自建项目(企业内训 · 自助)。 */
allowSelfProject?: boolean;
/** 客户在首页自助注册的试用企业。 */
trial?: boolean;
};
export type OpsProject = {
@@ -216,5 +218,7 @@ export const opsApi = {
campAccounts: (values: { projectId: number; rows: { nickname: string; username: string; phone?: string; email?: string; role: ProjectRole; dept?: string }[] }) =>
post<{ results: CampAccountResult[] }>("campAccounts", values),
campRemind: (values: { campId: number; projectIds?: number[] }) => post<{ sent: number }>("campRemind", values),
campInvites: (values: { campId: number; projectIds?: number[] }) =>
post<{ results: { projectId: number; enterprise: string; code: string; maxUses: number; expiresAt: string | null; created: boolean }[] }>("campInvites", values),
campClose: (values: { campId: number; projects: { projectId: number; service: "guided" | "self" }[] }) => post<{ updated: number }>("campClose", values),
};
+11
View File
@@ -105,3 +105,14 @@ export function progressOf(artifacts: ArtifactOverview[]): ArtifactProgress[] {
export function workspacePath(projectId: number, code: string) {
return `/projects/${projectId}/workspace/${encodeURIComponent(code)}`;
}
/** Where a stage tab leads: the first open table in the stage that is not 定版 yet, else the stage's first table. */
export function stageEntryCode(artifacts: ArtifactOverview[], stage: StageKey): string {
const map = byCode(artifacts);
const steps = STEP_ARTIFACTS.filter((a) => a.stage === stage);
const open = steps.find((def) => {
const a = map.get(def.code);
return !!a && a.unlock.unlocked && a.status !== "locked" && def.priority !== "P1";
});
return (open ?? steps[0]).code;
}
+1 -1
View File
@@ -276,7 +276,7 @@ export function buildRoute(stage: StageKey, artifacts: ArtifactOverview[], where
const counted = tasks.flatMap((t) => t.steps).filter((s) => !s.method);
return {
stage,
stageName: STAGES.find((s) => s.key === stage)!.name,
stageName: STAGES.find((s) => s.key === stage)!.flow,
tasks,
done: counted.filter((s) => s.state === "final").length,
total: counted.length,
+14
View File
@@ -0,0 +1,14 @@
import { hanchuan2026 } from "../../../server-plugins/scripts/demo-seed/hanchuan";
import { SAMPLE_ORG_UNITS } from "./sample";
let cache: Record<string, unknown> | null = null;
/** Every sample table by code (S1-01 …), from the demo seed of 瀚川燃气 2026. */
export function samplePayloads(): Record<string, unknown> {
if (!cache) {
const org = Object.fromEntries(SAMPLE_ORG_UNITS.map((u) => [u.name, u.id]));
cache = hanchuan2026(org);
}
return cache;
}
+42
View File
@@ -0,0 +1,42 @@
import type { ProjectOrgUnit } from "./api";
// 示例项目(2026-11 甲方反馈:两家演示企业只是案例,放进流程里当示例对照着看)。
// 内容直接取演示数据种子里的瀚川燃气 2026(虚构企业),只读展示,不连后端、不进任何企业空间。
// 这里只放轻量的元信息,表的内容在示例页里按需加载(sample-data.ts)。
// 部门和负责人(虚构,与演示数据一致:市场营销部赵部长、燃气运营部王经理、工程建设部孙工)。
const DEPARTMENTS: [string, string][] = [
["战略发展部", "钟部长"], ["市场营销部", "赵部长"], ["燃气运营部", "王经理"], ["工程建设部", "孙工"], ["安全质量部", "冯部长"],
["综合能源事业部", "韩总"], ["财务部", "周总监"], ["人力资源部", "唐部长"], ["信息中心", "许主任"],
];
const COMPANY_ID = 100;
export const SAMPLE = {
enterprise: "瀚川燃气有限公司",
shortName: "瀚川燃气",
project: "2026 年度战略落地",
year: 2026,
keyProjectLevel: 2 as const,
scheduleScale: "month" as const,
};
export const SAMPLE_ORG_UNITS: ProjectOrgUnit[] = [
{ id: COMPANY_ID, name: SAMPLE.enterprise, kind: "company", parentId: null, headId: null, headName: "张总", sort: 0 },
...DEPARTMENTS.map(([name, headName], i): ProjectOrgUnit => ({ id: i + 1, name, kind: "department", parentId: COMPANY_ID, headId: null, headName, sort: i + 1 })),
{ id: 20, name: "调度中心", kind: "team", parentId: 3, headId: null, headName: null, sort: 20 },
];
/** Tables the sample has, in book order (S1-04 IPOOC and S1-05 are not in the sample). */
export const SAMPLE_CODES = [
"S1-01", "S1-02", "S1-03", "S1-06", "S1-07",
"S2-03", "S2-01", "S2-08", "S2-04", "S2-05", "S2-06", "S2-07",
"S3-02", "S3-05", "S3-06", "S3-07", "S3-08",
];
export function hasSample(code: string): boolean {
return SAMPLE_CODES.includes(code);
}
export function samplePath(code?: string): string {
return code ? `/sample/${encodeURIComponent(code)}` : "/sample";
}
+42
View File
@@ -0,0 +1,42 @@
import { Link } from "react-router";
import { GUIDE_FAQ, GUIDE_INTRO, GUIDE_STEPS } from "@/lib/dingze/guide";
import { samplePath } from "@/lib/dingze/sample";
/** 使用指南:五步走完的简明说明,和发给客户的《三分钟上手》同一份内容。 */
export default function HelpPage() {
return (
<div className="flex max-w-3xl flex-col gap-6">
<div>
<h1 className="font-heading text-2xl font-bold text-brand">使用指南</h1>
<p className="mt-1 text-sm text-muted-foreground">{GUIDE_INTRO}</p>
</div>
<ol className="flex flex-col gap-3">
{GUIDE_STEPS.map((step) => (
<li key={step.title} className="flex gap-4 rounded-xl border bg-card p-4">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-gold font-heading font-bold text-brand">{step.no}</span>
<div className="min-w-0">
<h2 className="font-heading text-lg font-bold">{step.title}</h2>
<ul className="mt-1 flex list-disc flex-col gap-1 pl-4 text-sm">
{step.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</li>
))}
</ol>
<section className="rounded-xl border bg-card p-4">
<h2 className="font-heading text-lg font-bold">{GUIDE_FAQ.title}</h2>
<ul className="mt-1 flex list-disc flex-col gap-1 pl-4 text-sm">
{GUIDE_FAQ.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
<p className="text-sm text-muted-foreground">
想先看一套填好的表?打开<Link to={samplePath()} className="text-primary hover:underline">示例项目</Link>。
</p>
</div>
);
}
+60 -4
View File
@@ -1,5 +1,5 @@
import { ARTIFACTS, SERVICE_LABELS, STATUS_LABELS, type ServiceMode } from "@dingze/shared";
import { ArrowLeft, Bell, Building2, Download, Pencil, Plus, UserPlus } from "lucide-react";
import { ArrowLeft, Bell, Building2, Copy, Download, Pencil, Plus, Ticket, UserPlus } from "lucide-react";
import { useState } from "react";
import { Link, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
@@ -14,6 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { downloadCsv } from "@/lib/dingze/camp-import";
import { errorMessage } from "@/lib/dingze/errors";
import { inviteLink } from "@/lib/dingze/invite-api";
import { type Camp, type CampProject, opsApi } from "@/lib/dingze/ops-api";
import { useCampDetail, useOpsMutation } from "@/lib/dingze/ops-queries";
import { formatTime } from "@/lib/dingze/records";
@@ -120,7 +121,14 @@ function EmptyCamp() {
function PrepTab({ camp, projects, onAccounts }: { camp: Camp; projects: CampProject[]; onAccounts: (p: CampProject) => void }) {
const remind = useOpsMutation(opsApi.campRemind);
const invites = useOpsMutation(opsApi.campInvites);
const [codes, setCodes] = useState<{ projectId: number; enterprise: string; code: string; maxUses: number; expiresAt: string | null }[] | null>(null);
const notReady = projects.filter((p) => !ready(p));
const issueCodes = () =>
invites.mutate(
{ campId: camp.id },
{ onSuccess: (r) => setCodes(r.results), onError: (error) => toast.error(errorMessage(error)) },
);
const send = (projectIds?: number[]) =>
remind.mutate(
{ campId: camp.id, projectIds },
@@ -134,10 +142,16 @@ function PrepTab({ camp, projects, onAccounts }: { camp: Camp; projects: CampPro
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>开营前每家企业要准备好三件事:组织与部门、资料与画像、项目团队。企业项目负责人在项目首页能看到同样的清单。</span>
<Button size="sm" variant="outline" disabled={!notReady.length || remind.isPending} onClick={() => send()}>
<Bell /> 提醒未就绪的 {notReady.length} 家
</Button>
<span className="flex gap-2">
<Button size="sm" disabled={invites.isPending} onClick={issueCodes} title="每家企业一个免审核邀请码(项目成员 · 20 人 · 60 天),转发给企业自己发给参训人员">
<Ticket /> {invites.isPending ? "生成中…" : "一键发团队码"}
</Button>
<Button size="sm" variant="outline" disabled={!notReady.length || remind.isPending} onClick={() => send()}>
<Bell /> 提醒未就绪的 {notReady.length} 家
</Button>
</span>
</div>
{codes ? <TeamCodes codes={codes} onClose={() => setCodes(null)} /> : null}
<div className="overflow-x-auto rounded-xl border bg-card">
<Table>
<TableHeader>
@@ -377,3 +391,45 @@ function CloseTab({ camp, projects }: { camp: Camp; projects: CampProject[] }) {
</div>
);
}
/** 团队码清单:每家企业一行,复制全部或单条。企业把码转给自己的人,凭码注册就直接进项目。 */
function TeamCodes({ codes, onClose }: { codes: { projectId: number; enterprise: string; code: string; maxUses: number; expiresAt: string | null }[]; onClose: () => void }) {
const line = (c: (typeof codes)[number]) => `${c.enterprise}:${inviteLink(c.code)} · 邀请码 ${c.code}`;
const copy = async (text: string, what: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`已复制${what}`);
} catch {
toast.error("复制失败,请手动复制");
}
};
return (
<section aria-label="团队码" className="flex flex-col gap-2 rounded-xl border bg-card p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">团队码已生成({codes.length} 家)</span>
<span className="text-xs text-muted-foreground">每个码 20 人、60 天内有效,凭码注册直接进项目(项目成员)。把下面的链接发给各企业对接人。</span>
<span className="ml-auto flex gap-1">
<Button size="sm" variant="outline" onClick={() => copy(codes.map(line).join("\n"), "全部团队码")}>
<Copy /> 复制全部
</Button>
<Button size="sm" variant="ghost" onClick={onClose}>
收起
</Button>
</span>
</div>
<ul className="divide-y">
{codes.map((c) => (
<li key={c.projectId} className="flex flex-wrap items-center gap-3 py-1.5">
<span className="w-40 truncate font-medium">{c.enterprise}</span>
<span className="font-mono tracking-wider">{c.code}</span>
<span className="text-xs text-muted-foreground">{c.maxUses} 人 · 到 {c.expiresAt ? formatTime(c.expiresAt).slice(0, 10) : "不限"}</span>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => copy(line(c), `${c.enterprise}的团队码`)} aria-label={`复制 ${c.enterprise} 的团队码`}>
<Copy />
</Button>
</li>
))}
</ul>
</section>
);
}
+15 -3
View File
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
@@ -40,7 +41,8 @@ function useInviteMutation<TVars, TResult>(enterpriseId: number, fn: (vars: TVar
}
function scopeText(i: Invite) {
return [i.projectName && `项目「${i.projectName}」`, i.projectRole && PROJECT_ROLE_LABELS[i.projectRole], i.orgUnitName].filter(Boolean).join(" · ") || "只加入企业";
const scope = [i.projectName && `项目「${i.projectName}」`, i.projectRole && PROJECT_ROLE_LABELS[i.projectRole], i.orgUnitName].filter(Boolean).join(" · ") || "只加入企业";
return i.autoApprove ? `${scope} · 免审核` : scope;
}
function inviteState(i: Invite): { label: string; muted: boolean } {
@@ -185,7 +187,7 @@ export function InvitesTab({ detail, asOps }: { detail: EnterpriseDetail; asOps:
disabled={save.isPending}
onClick={() =>
save.mutate(
{ enterpriseId, id: i.id, projectId: i.projectId, projectRole: i.projectRole, orgUnitId: i.orgUnitId, maxUses: i.maxUses, expiresAt: i.expiresAt, note: i.note ?? undefined, status: i.status === "active" ? "disabled" : "active" },
{ enterpriseId, id: i.id, projectId: i.projectId, projectRole: i.projectRole, orgUnitId: i.orgUnitId, maxUses: i.maxUses, expiresAt: i.expiresAt, note: i.note ?? undefined, autoApprove: i.autoApprove, status: i.status === "active" ? "disabled" : "active" },
{ onSuccess: () => toast.success(i.status === "active" ? "已停用,这个码不能再用来申请" : "已重新启用"), onError: (e) => toast.error(errorMessage(e)) },
)
}
@@ -269,6 +271,8 @@ function InviteDialog({
const [maxUses, setMaxUses] = useState("20");
const [expiresAt, setExpiresAt] = useState(() => new Date(Date.now() + 14 * 86400_000).toISOString().slice(0, 10));
const [note, setNote] = useState("");
// 默认免审核:训练营和内训都是几十人一起进来,审核一遍太重;需要把关时再关掉。
const [autoApprove, setAutoApprove] = useState(true);
const [busy, setBusy] = useState(false);
const roles = ENTERPRISE_ROLES.filter((r) => asOps || r !== "ent_lead");
const submit = async (event: React.FormEvent) => {
@@ -284,6 +288,7 @@ function InviteDialog({
// 到期日当天整天有效。
expiresAt: expiresAt ? new Date(`${expiresAt}T23:59:59`).toISOString() : null,
note: note.trim() || undefined,
autoApprove,
});
onClose();
onCreated({ ...invite, used: 0, projectName: null, orgUnitName: null, createdBy: null } as Invite);
@@ -301,7 +306,7 @@ function InviteDialog({
<DialogTitle className="flex items-center gap-2">
<Ticket className="size-4" /> 新建邀请码
</DialogTitle>
<DialogDescription>凭码申请的人审核通过后成为「{detail.enterprise.shortName}」的企业成员,并按下面的设置加入项目。建好后会自动复制邀请链接。</DialogDescription>
<DialogDescription>凭码注册的人成为「{detail.enterprise.shortName}」的企业成员,并按下面的设置加入项目。建好后会自动复制邀请链接。</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
@@ -349,6 +354,13 @@ function InviteDialog({
<Input id="inv-expires" type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
</Field>
</div>
<label className="flex items-start gap-2 text-sm">
<Checkbox checked={autoApprove} onCheckedChange={(v) => setAutoApprove(v === true)} className="mt-0.5" />
<span>
免审核
<span className="block text-xs text-muted-foreground">勾上:凭码注册马上开通、直接进项目。不勾:先申请,由管理员审核后开通。</span>
</span>
</label>
<Field>
<FieldLabel htmlFor="inv-note">备注</FieldLabel>
<Input id="inv-note" value={note} onChange={(e) => setNote(e.target.value)} placeholder="例如:储能事业部新同事" maxLength={100} />
+4 -1
View File
@@ -86,7 +86,10 @@ export default function EnterprisesPage() {
<TableRow key={e.id}>
<TableCell>
<div className="font-medium">{e.name}</div>
<div className="text-xs text-muted-foreground">{e.shortName}</div>
<div className="text-xs text-muted-foreground">
{e.shortName}
{e.trial ? <Badge variant="secondary" className="ml-2">自助试用</Badge> : null}
</div>
</TableCell>
<TableCell className="hidden md:table-cell">{SIZE_LABELS[e.size]}</TableCell>
<TableCell>
+1 -1
View File
@@ -61,7 +61,7 @@ export default function ArtifactsPage({ embedded = false }: { embedded?: boolean
<Badge variant="outline" className="h-4 px-1 text-[10px]">
{priorityLabel(def.priority)}
</Badge>
{STAGES.find((s) => s.key === def.stage)?.name}
{STAGES.find((s) => s.key === def.stage)?.flow}
</div>
</td>
<td className="px-3 py-2">
@@ -0,0 +1,90 @@
import { ArrowRight } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { GUIDE_STEPS } from "@/lib/dingze/guide";
import { samplePath } from "@/lib/dingze/sample";
import { usePersistentFlag } from "@/lib/dingze/view-prefs";
const KEY = "dingze:first-run-guide";
/** 本机是否已看过首次指引。 */
export function useFirstRunGuide(): [boolean, (seen: boolean) => void] {
const [seen, setSeen] = usePersistentFlag(KEY);
return [seen === true, setSeen];
}
/**
* 首次进项目的操作指引:五步各一句话,一页看完。关掉后本机不再弹;
* 左侧「使用指南」随时能再看完整说明。
*/
export function FirstRunGuide({ open, onClose, startHref }: { open: boolean; onClose: () => void; startHref: string }) {
// 注册那一步已经做完了,从第 2 步“上传资料”开始讲。
const steps = GUIDE_STEPS.filter((s) => s.no !== 1);
const [page, setPage] = useState(0);
const step = steps[page];
const last = page === steps.length - 1;
return (
<Dialog open={open} onOpenChange={(o) => (!o ? onClose() : undefined)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>欢迎,先看一眼怎么走</DialogTitle>
<DialogDescription>一共 {steps.length} 步,做完就有年度经营计划书。每一步右边都有数字咨询师,不会填就问它。</DialogDescription>
</DialogHeader>
<ol className="flex gap-1" aria-label="进度">
{steps.map((s, i) => (
<li key={s.title} className="flex-1">
<button
type="button"
onClick={() => setPage(i)}
aria-current={i === page ? "step" : undefined}
className={`flex w-full flex-col items-center gap-1 rounded-md px-1 py-1.5 text-xs ${i === page ? "bg-accent font-semibold" : "text-muted-foreground hover:bg-muted"}`}
>
<span className={`flex size-6 items-center justify-center rounded-full font-bold ${i === page ? "bg-primary text-primary-foreground" : "bg-muted"}`}>{s.no! - 1}</span>
{s.title}
</button>
</li>
))}
</ol>
<div className="min-h-32 rounded-xl border bg-card p-4">
<h3 className="font-heading text-lg font-bold">
第 {step.no! - 1} 步 · {step.title}
</h3>
<ul className="mt-2 flex list-disc flex-col gap-1 pl-4 text-sm">
{step.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<DialogFooter className="flex-wrap gap-2 sm:justify-between">
<span className="flex gap-2">
<Button variant="ghost" size="sm" nativeButton={false} render={<Link to={samplePath()} target="_blank" rel="noopener" />}>
先看一套填好的示例
</Button>
<Button variant="ghost" size="sm" nativeButton={false} render={<Link to="/help" />} onClick={onClose}>
完整指南
</Button>
</span>
<span className="flex gap-2">
{page > 0 ? (
<Button variant="outline" onClick={() => setPage(page - 1)}>
上一步
</Button>
) : null}
{last ? (
<Button nativeButton={false} render={<Link to={startHref} />} onClick={onClose}>
开始:上传资料 <ArrowRight />
</Button>
) : (
<Button onClick={() => setPage(page + 1)}>
下一步 <ArrowRight />
</Button>
)}
</span>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+4 -4
View File
@@ -76,7 +76,7 @@ function StageBar({ current, stages }: { current: StageKey; stages: RailStage[]
return (
<ol aria-label="三阶段" className="flex min-w-0 flex-1">
{stages.map((st, i) => {
const name = STAGES.find((x) => x.key === st.key)!.name.replace(/责$/, "");
const name = STAGES.find((x) => x.key === st.key)!.flow;
const now = st.key === current;
const body = (
<>
@@ -92,11 +92,11 @@ function StageBar({ current, stages }: { current: StageKey; stages: RailStage[]
return (
<li key={st.key} className={cn("min-w-0 flex-1", i > 0 && "-ml-1")} style={{ clipPath: CHEVRON[i] }}>
{st.open && !now ? (
<Link to={st.to} className={cls} title={`切换到${STAGES[i].name}(必做表已定版 ${st.locked} / ${st.gateTotal})`}>
<Link to={st.to} className={cls} title={`切换到${STAGES[i].flow}(必做表已定版 ${st.locked} / ${st.gateTotal})`}>
{body}
</Link>
) : (
<span className={cls} aria-current={now ? "step" : undefined} title={st.open ? STAGES[i].name : `${STAGES[i].name}:上一阶段全部定版后开启`}>
<span className={cls} aria-current={now ? "step" : undefined} title={st.open ? STAGES[i].flow : `${STAGES[i].flow}:上一步全部定版后开启`}>
{body}
</span>
)}
@@ -210,7 +210,7 @@ export function RouteRail({
<div className="mt-2.5 px-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 font-heading text-[15px] font-bold leading-snug">
第{["一", "二", "三"][STAGES.findIndex((x) => x.key === route.stage)]}阶段 {route.stageName}
第 {STAGES.findIndex((x) => x.key === route.stage) + 2} 步 · {route.stageName}
</div>
<button type="button" onClick={onToggle} aria-label="收起项目路线" title="收起(窄屏时自动收起)" className="flex size-6 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-muted">
<ChevronsLeft className="size-3.5" />
+2 -2
View File
@@ -43,7 +43,7 @@ export function StepBar({ projectId, artifacts, stage, activeCode }: StepBarProp
)}
>
{open ? null : <Lock className="size-3" aria-label="待解锁" />}
{s.name}
{s.flow}
</Link>
);
})}
@@ -51,7 +51,7 @@ export function StepBar({ projectId, artifacts, stage, activeCode }: StepBarProp
<div>
<div className="font-heading text-base font-bold">
{STAGES.find((s) => s.key === stage)!.name} · {progress.goal}
{STAGES.find((s) => s.key === stage)!.flow} · {progress.goal}
</div>
<div className="text-xs text-muted-foreground">
P0 已定版 {progress.locked} / {progress.gateTotal},全部定版后开启下一阶段
+4 -2
View File
@@ -7,6 +7,7 @@ import { type DecodeMap, type GoalPathSystem, type Perspective } from "@dingze/s
import { FIGURE_3_7, type Figure37Item, figureTag } from "@/lib/dingze/figure-3-7";
import { workspacePath } from "@/lib/dingze/progress";
import { cn } from "@/lib/utils";
import { samplePath } from "@/lib/dingze/sample";
import { useProjectContext } from "@/pages/projects/project-context";
// 找路径的五个子步骤 (design 10): 1–4 are the four layers of the decode map (S2-01), 5 imports
@@ -33,7 +34,8 @@ export function FindPathSteps({
/** Inside S2-01: switch the layer in place instead of navigating. */
onPick?: (no: number) => void;
}) {
const { project } = useProjectContext();
// 示例页(/sample)没有项目上下文,子步骤链到示例里的同一张表。
const project = useProjectContext()?.project;
const themes = map?.themes ?? [];
const count = (step: (typeof FIND_PATH_STEPS)[number]) =>
step.perspective ? themes.filter((t) => t.perspective === step.perspective).length : (paths?.nodes ?? []).length;
@@ -64,7 +66,7 @@ export function FindPathSteps({
</button>
);
}
const to = inMap ? `${workspacePath(project.id, "S2-01")}?sub=${step.no}` : workspacePath(project.id, "S2-03");
const to = !project ? samplePath(inMap ? "S2-01" : "S2-03") : inMap ? `${workspacePath(project.id, "S2-01")}?sub=${step.no}` : workspacePath(project.id, "S2-03");
return (
<Link key={step.no} to={to} aria-current={current ? "step" : undefined} className={className}>
{body}
@@ -168,7 +168,7 @@ export function DeptPlanBookEditor({
enterprise: string;
projectYear: number;
}) {
const { project } = useProjectContext();
const project = useProjectContext()?.project;
const invalid = errorAnchors(issues);
const depts = value.depts ?? [];
const [tab, setTab] = useState<string | null>(null);
@@ -186,7 +186,7 @@ export function DeptPlanBookEditor({
onClick={() => onChange({ depts: [...depts, ...missing.map(([deptId, deptName]) => ({ id: deptId, deptId, deptName, ...emptyPlanBookText() }))] })}
/>
</EditorToolbar>
<SourceNote to={workspacePath(project.id, "S2-05")} linkLabel="打开部门目标承接">
<SourceNote to={project ? workspacePath(project.id, "S2-05") : undefined} linkLabel="打开部门目标承接">
部门页签来自 S2-05《部门目标承接》里的承接部门({known.size} 个){missing.length ? `,其中 ${missing.map(([, n]) => n).join("、")} 还没有建部门计划书${readOnly ? "(解锁后可用“按部门承接表建立部门计划书”补上)" : ",点上方按钮补上"}` : ""}。组织里新增的部门,要先在部门承接里承接目标,才会出现在这里;部门改名不会改已写好的计划书。
</SourceNote>
{depts.length === 0 ? (
+3 -2
View File
@@ -48,7 +48,8 @@ const LETTER_TONE: Record<RaciLetter, string> = {
/** S2-04: rows are key projects (or key paths), columns are departments; A = 1 and R ≥ 1 per row. */
export function RaciEditor({ value, onChange, readOnly, issues, upstream, orgUnits }: Props) {
const { project } = useProjectContext();
// 示例页(/sample)没有项目上下文:链接到项目页面的地方不显示链接。
const project = useProjectContext()?.project;
const invalid = errorAnchors(issues);
const [newColumn, setNewColumn] = useState("");
const columns = value.columns ?? [];
@@ -73,7 +74,7 @@ export function RaciEditor({ value, onChange, readOnly, issues, upstream, orgUni
const headless = depts.filter((d) => !d.headId && !d.headName).map((d) => d.name);
return (
<section aria-label="RACI 责任分解矩阵" className="flex flex-col gap-4">
<SourceNote to={`/projects/${project.id}/materials#org`} linkLabel="看组织与职责" warn={!depts.length}>
<SourceNote to={project ? `/projects/${project.id}/materials#org` : undefined} linkLabel="看组织与职责" warn={!depts.length}>
{!depts.length
? "组织里还没有部门:责任者列只能手动加。请企业管理员先在「本企业 › 组织部门」建好部门和负责人。"
: `责任者列取自组织部门(${depts.length} 个)${headless.length ? `;${headless.join("、")} 还没写负责人` : ""},表头小字是部门职责,定 A、R 时对照着看。组织之外的责任者可以加自定义列;定版后组织再变,这里只提示、不自动改。`}
+8 -3
View File
@@ -9,6 +9,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/
import { Skeleton } from "@/components/ui/skeleton";
import { nextActionPath } from "@/lib/dingze/progress";
import { useMyProjects } from "@/lib/dingze/queries";
import { samplePath } from "@/lib/dingze/sample";
const SCENE_LABELS = { camp: "训练营", inhouse: "企业内训" } as const;
@@ -19,7 +20,9 @@ export default function ProjectsPage() {
<div className="flex flex-col gap-6">
<div>
<h1 className="font-heading text-2xl font-bold text-brand">我的项目</h1>
<p className="mt-1 text-sm text-muted-foreground">往哪打 · 怎么走 · 谁负责 —— 选择一个项目继续定三责。</p>
<p className="mt-1 text-sm text-muted-foreground">
选一个项目继续。不知道怎么填,先看<Link to={samplePath()} className="text-primary hover:underline">示例项目</Link>。
</p>
</div>
{error ? (
@@ -40,7 +43,9 @@ export default function ProjectsPage() {
<FolderKanban />
</EmptyMedia>
<EmptyTitle>还没有加入任何项目</EmptyTitle>
<EmptyDescription>项目由运营管理员开通。开通后,你会在这里看到并进入项目。</EmptyDescription>
<EmptyDescription>
项目开通后会出现在这里。可以先看看<Link to={samplePath()} className="text-primary hover:underline">示例项目</Link>。
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
@@ -65,7 +70,7 @@ export default function ProjectsPage() {
return (
<div key={stage.key} className="flex flex-col gap-1 text-xs">
<div className="flex justify-between text-muted-foreground">
<span>{stage.name}</span>
<span>{stage.flow}</span>
<span className="tabular-nums">
{p.locked}/{p.total}
</span>
+35 -17
View File
@@ -34,6 +34,7 @@ import { SEARCH_MATERIALS_TOOL } from "@/lib/dingze/material-tools";
import { type MaterialSummary, downloadOriginal, useAddMaterial, useDeleteMaterial, useMaterials, useProfile, useSaveProfile } from "@/lib/dingze/materials-api";
import { opsApi } from "@/lib/dingze/ops-api";
import { formatTime } from "@/lib/dingze/records";
import { stageEntryCode, workspacePath } from "@/lib/dingze/progress";
import { cn } from "@/lib/utils";
import { OrgUnitsEditor } from "../ops/components/org-units-tab";
@@ -108,7 +109,7 @@ function MaterialLibrary({ materials, canEdit, canDeleteAny, currentUserId }: {
<section aria-labelledby="library" className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<h2 id="library" className="font-heading text-lg font-bold text-brand">
资料库
① 上传资料
</h2>
<span className="text-xs text-muted-foreground">保存原件,并提取文字供数字咨询师检索、注明出处;只有本项目成员能查看和下载</span>
{canEdit ? (
@@ -124,9 +125,22 @@ function MaterialLibrary({ materials, canEdit, canDeleteAny, currentUserId }: {
) : null}
</div>
{materials.length === 0 ? (
<p className="rounded-xl border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
还没有资料。可上传原件:Word(docx)、PDF、Excel(xlsx、xls、xlsm、xlsb、ods)、txt、md、csv(不超过 20 MB);也可以直接粘贴文字。
</p>
<div className="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed px-4 py-10 text-center">
<FileUp className="size-8 text-brand" />
<div className="font-heading text-lg font-bold">把企业简介、组织架构、上年经营复盘传上来</div>
<p className="max-w-md text-sm text-muted-foreground">Word、PDF、Excel、txt 都可以,单个不超过 20 MB;也可以直接粘贴文字。传好后,数字咨询师会据此整理企业画像。</p>
{canEdit ? (
<div className="mt-1 flex gap-2">
<Button size="lg" disabled={busy} onClick={() => fileRef.current?.click()}>
<FileUp /> {busy ? "上传中…" : "选择文件上传"}
</Button>
<Button size="lg" variant="outline" onClick={() => setPasting(true)}>
<ClipboardPaste /> 粘贴文字
</Button>
</div>
) : null}
<p className="text-xs text-muted-foreground">没有资料也可以先去填表,以后再补。</p>
</div>
) : (
<ul className="overflow-hidden rounded-xl border bg-card">
{materials.map((m) => (
@@ -185,10 +199,11 @@ function MaterialLibrary({ materials, canEdit, canDeleteAny, currentUserId }: {
}
/** 资料与画像: the enterprise's materials and the profile (事实 / 假设 / 未知 / 待补) built from them. */
// 顺序按客户做事的顺序:先传资料,再整理画像,再填组织(2026-11 甲方反馈:上传按钮藏太深)。
const MATERIAL_TABS = [
["profile", "① 企业画像"],
["org", "② 组织与职责"],
["library", "资料库"],
["library", "① 上传资料"],
["profile", "② 企业画像"],
["org", "③ 组织与职责"],
] as const;
type MaterialTab = (typeof MATERIAL_TABS)[number][0];
@@ -249,8 +264,8 @@ export default function MaterialsPage() {
const { hash } = useLocation();
const [params, setParams] = useSearchParams();
const fromHash = hash === "#org" ? "org" : hash === "#library" ? "library" : hash === "#profile" ? "profile" : null;
const tab: MaterialTab = (MATERIAL_TABS.map(([k]) => k) as string[]).includes(params.get("tab") ?? "") ? (params.get("tab") as MaterialTab) : (fromHash ?? "profile");
const setTab = (next: MaterialTab) => setParams(next === "profile" ? {} : { tab: next }, { replace: true });
const tab: MaterialTab = (MATERIAL_TABS.map(([k]) => k) as string[]).includes(params.get("tab") ?? "") ? (params.get("tab") as MaterialTab) : (fromHash ?? "library");
const setTab = (next: MaterialTab) => setParams(next === "library" ? {} : { tab: next }, { replace: true });
const { project, overview } = useProjectContext();
const materials = useMaterials(project);
const profile = useProfile(project);
@@ -377,10 +392,18 @@ export default function MaterialsPage() {
{/* 标题和页签固定在上面,内容区自己滚动(2026-10-16)。 */}
<div className="shrink-0 border-b bg-background px-4 pt-4 md:px-6">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="font-heading text-2xl font-bold text-brand">资料</h1>
<h1 className="font-heading text-2xl font-bold text-brand">
<span className="mr-2 text-base font-semibold text-muted-foreground">第 1 步</span>上传资料
</h1>
<p className="min-w-0 flex-1 text-sm text-muted-foreground">
填表前先备好企业画像和组织架构:直接填,或先上传资料再让数字咨询师整理,你确认后才写入。
上传企业简介、组织架构、上年经营复盘,让数字咨询师整理企业画像,你确认后才写入。试用期间请用脱敏资料。
</p>
<Link
to={workspacePath(project.id, stageEntryCode(overview.artifacts, "strategy"))}
className="shrink-0 self-center text-sm font-semibold text-primary hover:underline"
>
下一步:战略解码 →
</Link>
</div>
<nav aria-label="资料页签" role="tablist" className="mt-3 -mb-px flex flex-wrap items-end gap-1 text-sm">
{MATERIAL_TABS.map(([key, label]) => {
@@ -406,11 +429,6 @@ export default function MaterialsPage() {
);
})}
<span className="ml-auto flex items-center gap-3 pb-2">
{canEdit && tab !== "library" ? (
<button type="button" onClick={() => setTab("library")} className="flex items-center gap-1 text-primary hover:underline">
<FileUp className="size-3.5" /> 先上传资料
</button>
) : null}
<Link to={`/projects/${project.id}/results`} className="flex items-center gap-1 text-primary hover:underline" title="表一定版就成为资料的一部分;查看、对比、下载在「成果」">
<FileCheck2 className="size-3.5" /> 已定版 {lockedCount} 张 →
</Link>
@@ -430,7 +448,7 @@ export default function MaterialsPage() {
<section aria-labelledby="profile" className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<h2 id="profile" className="font-heading text-lg font-bold text-brand">
① 企业画像
② 企业画像
</h2>
{profile.data?.updatedAt ? (
<span className="text-xs text-muted-foreground">
+107 -14
View File
@@ -1,18 +1,21 @@
import { ArrowRight, CircleCheck, CircleDashed, CircleDot, Import } from "lucide-react";
import { useState } from "react";
import { ArrowRight, Check, CircleCheck, CircleDashed, CircleDot, Import, Lock } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { collaborationStatus, getArtifactDef, nextAction } from "@dingze/shared";
import { STAGES, collaborationStatus, getArtifactDef, nextAction } from "@dingze/shared";
import { StatusBadge, StatusDot } from "@/components/dingze/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { useMaterials, useProfile } from "@/lib/dingze/materials-api";
import { STEP_ARTIFACTS, nextActionPath, progressOf, stageProgress, workspacePath } from "@/lib/dingze/progress";
import { type FlowStep, flowSteps } from "@/lib/dingze/flow";
import type { ArtifactOverview } from "@/lib/dingze/api";
import { STEP_ARTIFACTS, nextActionPath, progressOf, stageEntryCode, stageProgress, workspacePath } from "@/lib/dingze/progress";
import { type StartupItem, startupChecklist } from "@/lib/dingze/project-startup";
import { cn } from "@/lib/utils";
import { FirstRunGuide, useFirstRunGuide } from "./components/first-run-guide";
import { ImportDialog } from "./components/import-dialog";
import { useProjectContext } from "./project-context";
@@ -35,6 +38,29 @@ export default function ProjectOverviewPage() {
const service = (overview.project.service ?? project.service) === "self" ? "self" : "guided";
const next = nextAction(steps, role, service);
const stages = stageProgress(artifacts);
const materials = useMaterials(project);
const profile = useProfile(project);
const startup = startupChecklist({ orgUnits: overview.orgUnits ?? [], team: overview.team ?? [], materialCount: materials.data?.length ?? 0, profileItems: profile.data?.items ?? [] });
const flow = flowSteps({
stages,
materialsReady: startup.find((i) => i.key === "materials")?.state === "done",
materialCount: materials.data?.length ?? 0,
started: artifacts.some((a) => a.status !== "not_started"),
});
const materialsFirst = !materials.isLoading && !profile.isLoading && flow[0].state === "current" && role !== "readonly";
// 首次进项目弹操作指引(本机记住)。只给企业侧的新项目:还没开始填表、不是咨询方。
const [guideSeen, setGuideSeen] = useFirstRunGuide();
const [guideOpen, setGuideOpen] = useState(false);
const freshProject = !artifacts.some((a) => a.status !== "not_started");
useEffect(() => {
if (!guideSeen && !consulting && freshProject && !materials.isLoading && !profile.isLoading) setGuideOpen(true);
// 只在首次加载完判断一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [materials.isLoading, profile.isLoading]);
const closeGuide = () => {
setGuideOpen(false);
setGuideSeen(true);
};
// 训练营和企业内训对企业是同一套界面;场景只给咨询侧看。
const consulting = overview.isConsultAdmin || role === "lead_consultant" || role === "co_consultant";
// Everything else that is moving: returned, in review, waiting for 定版, upstream changed.
@@ -55,14 +81,20 @@ export default function ProjectOverviewPage() {
return (
<div className="mx-auto flex w-full max-w-[1360px] flex-col gap-5 px-4 py-6 md:px-7">
{canImport ? <ImportDialog project={project} open={importOpen} onOpenChange={closeImport} /> : null}
<FirstRunGuide open={guideOpen} onClose={closeGuide} startHref={`/projects/${project.id}/materials`} />
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm text-muted-foreground">
{project.year} 年度{consulting ? ` · ${project.scene === "camp" ? "训练营" : "企业内训"}` : ""}
{service === "self" ? " · 自助(数字咨询师陪你做,复核由企业项目负责人对照书中门禁自查)" : ""} · 往哪打 · 怎么走 · 谁负责
</p>
<h1 className="font-heading text-3xl font-bold text-brand">待办</h1>
<p className="mt-1 text-sm text-muted-foreground">先做最上面那件事;下面是正在流转的表和项目启动还缺的准备。</p>
<h1 className="font-heading text-3xl font-bold text-brand">总览</h1>
<p className="mt-1 text-sm text-muted-foreground">
按 1 → 5 往下走。先做蓝色按钮上的那件事。
<button type="button" onClick={() => setGuideOpen(true)} className="ml-2 text-primary hover:underline">
看操作指引
</button>
</p>
</div>
{canImport ? (
<Button variant="outline" onClick={() => setImportOpen(true)} title="把本企业已有项目里的表带进来作为草稿,再按今年改">
@@ -71,18 +103,31 @@ export default function ProjectOverviewPage() {
) : null}
</div>
<StartupCard projectId={project.id} />
<FlowStrip projectId={project.id} steps={flow} artifacts={artifacts} />
<section aria-label="待办" className="flex flex-col gap-3 rounded-2xl border bg-card p-4">
<section aria-label="下一步" className="flex flex-col gap-3 rounded-2xl border bg-card p-4">
{materialsFirst ? (
<Link
to={`/projects/${project.id}/materials`}
className="flex flex-wrap items-center gap-3 rounded-xl bg-primary px-5 py-4 text-primary-foreground hover:bg-primary/90"
>
<span className="min-w-0 flex-1">
<span className="block text-xs opacity-80">第 1 步</span>
<span className="block text-lg font-bold">上传资料</span>
<span className="block text-sm opacity-85">传企业简介、组织架构、上年经营复盘,数字咨询师据此整理企业画像。也可以先跳过,直接去战略解码。</span>
</span>
<ArrowRight className="size-5 shrink-0" />
</Link>
) : null}
<Link
to={nextActionPath(project.id, next)}
className={cn(
"flex flex-wrap items-center gap-3 rounded-xl px-5 py-4",
next.kind === "waiting" || next.kind === "view" ? "bg-muted text-foreground hover:bg-muted/80" : "bg-primary text-primary-foreground hover:bg-primary/90"
next.kind === "waiting" || next.kind === "view" || materialsFirst ? "bg-muted text-foreground hover:bg-muted/80" : "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
<span className="min-w-0 flex-1">
<span className="block text-xs opacity-80">{next.kind === "waiting" ? "现在在等" : "下一步"}</span>
<span className="block text-xs opacity-80">{next.kind === "waiting" ? "现在在等" : materialsFirst ? "或者直接开始" : "下一步"}</span>
<span className="block text-lg font-bold">{next.label}</span>
<span className="block text-sm opacity-85">{next.hint}</span>
</span>
@@ -115,13 +160,15 @@ export default function ProjectOverviewPage() {
)}
</section>
<StartupCard projectId={project.id} />
<section aria-label="三阶段进度" className="grid gap-4 lg:grid-cols-3">
{stages.map((stage) => (
<Card key={stage.stage} className={cn(stage.open && !stage.complete && "border-primary ring-3 ring-accent")}>
<CardHeader className="gap-2">
<div className="flex items-baseline gap-2">
<CardTitle className="text-lg text-brand">{stage.name}</CardTitle>
<span className="text-xs text-muted-foreground">{stage.goal}</span>
<CardTitle className="text-lg text-brand">{STAGES.find((s) => s.key === stage.stage)?.flow}</CardTitle>
<span className="text-xs text-muted-foreground">{stage.name} · {stage.goal}</span>
<span className="ml-auto text-xs font-semibold text-muted-foreground">
{stage.complete ? "已完成" : stage.open ? "进行中" : "待解锁"}
</span>
@@ -129,7 +176,7 @@ export default function ProjectOverviewPage() {
<Progress value={(stage.locked / stage.gateTotal) * 100} aria-label={`${stage.name}定版进度`} />
<p className="text-xs text-muted-foreground">
必做表已定版 {stage.locked} / {stage.gateTotal}
{stage.open ? "" : " · 上一阶段全部定版后开启"}
{stage.open ? "" : " · 上一步全部定版后开启"}
</p>
</CardHeader>
<CardContent>
@@ -241,7 +288,9 @@ function StartupCard({ projectId }: { projectId: number }) {
{link.label} →
</Link>
) : item.key === "team" && item.state !== "done" ? (
<span className="mt-auto text-xs text-muted-foreground">成员和角色由咨询机构运营在“项目与分派”里设置,有调整请告诉主咨询师。</span>
<span className="mt-auto text-xs text-muted-foreground">
叫同事一起做:企业管理员在<Link to="/enterprise?tab=invites" className="text-primary hover:underline">本企业 › 邀请注册</Link>发一个邀请码,同事凭码注册就进项目;或请运营分派。
</span>
) : null}
</li>
);
@@ -250,3 +299,47 @@ function StartupCard({ projectId }: { projectId: number }) {
</section>
);
}
/** 五步流程条:资料 → 战略解码 → 目标分解 → 计划制定 → 下载成果,标出做完的、当前的和还没开启的。 */
function FlowStrip({ projectId, steps, artifacts }: { projectId: number; steps: FlowStep[]; artifacts: ArtifactOverview[] }) {
const href = (step: FlowStep) =>
step.key === "materials"
? `/projects/${projectId}/materials`
: step.key === "deliver"
? `/projects/${projectId}/results`
: workspacePath(projectId, stageEntryCode(artifacts, step.key));
return (
<ol aria-label="流程" className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
{steps.map((step) => (
<li key={step.key}>
<Link
to={href(step)}
aria-current={step.state === "current" ? "step" : undefined}
className={cn(
"flex h-full flex-col gap-1 rounded-xl border bg-card p-3 hover:border-primary",
step.state === "current" && "border-primary ring-3 ring-accent",
step.state === "locked" && "opacity-60",
)}
>
<span className="flex items-center gap-2">
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold",
step.state === "done" ? "bg-status-done text-status-done-foreground" : step.state === "current" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
>
{step.state === "done" ? <Check className="size-3.5" /> : step.no}
</span>
<span className="font-heading font-bold text-brand">{step.label}</span>
{step.state === "locked" ? <Lock className="ml-auto size-3.5 text-muted-foreground" aria-label="待开启" /> : null}
{step.state === "current" ? <span className="ml-auto text-xs font-semibold text-primary">现在</span> : null}
</span>
<span className="text-xs text-muted-foreground">{step.hint}</span>
<span className="mt-auto text-xs">{step.detail}</span>
</Link>
</li>
))}
</ol>
);
}
+69 -29
View File
@@ -1,6 +1,6 @@
import { Check, ChevronDown } from "lucide-react";
import { Check, ChevronDown, Lock } from "lucide-react";
import { useState } from "react";
import { Link, NavLink, Outlet, useLocation, useMatch, useNavigate, useParams } from "react-router";
import { Link, Outlet, useLocation, useMatch, useNavigate, useParams } from "react-router";
import { UserDropdown } from "@/components/app-shell/header";
import { NotificationBell } from "@/components/dingze/notification-bell";
@@ -15,21 +15,39 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { PROJECT_ROLE_LABELS, type ProjectRole } from "@dingze/shared";
import type { ProjectSummary } from "@/lib/dingze/api";
import { ARTIFACTS, PROJECT_ROLE_LABELS, STAGES, type ProjectRole } from "@dingze/shared";
import type { ArtifactOverview, ProjectSummary } from "@/lib/dingze/api";
import { stageEntryCode, stageProgress } from "@/lib/dingze/progress";
import { useProject, useProjectOverview } from "@/lib/dingze/queries";
import { cn } from "@/lib/utils";
import type { ProjectContextValue } from "./project-context";
// 一级入口两端同名(开营前 UX 改版 IA1):待办 · 定责 · 资料 · 成果;咨询方另有专家咨询。
const ENTERPRISE_NAV = [
{ to: "overview", label: "待办", title: "待办:要你处理的表、等别人处理的表和项目启动事项" },
{ to: "workspace", label: "定责", title: "定责:逐表填写三张检查表,完成后提交复核、定版" },
{ to: "materials", label: "资料", title: "资料:填表前的准备与参考——资料库、组织与职责、企业画像" },
{ to: "results", label: "成果", title: "成果:每张表的状态与版本,已定版的交付文件" },
];
const FULL_NAV = [...ENTERPRISE_NAV, { to: "expert", label: "专家咨询", title: "专家咨询:方法流程解决不了的关键分歧,由人工咨询师受理" }];
// 项目导航按客户流程排(2026-11 甲方反馈):总览 · ① 资料 · ② 战略解码 · ③ 目标分解 · ④ 计划制定 · ⑤ 成果;
// 咨询方另有专家咨询。三个阶段各自跳到本阶段第一张还没定版的表。
type NavItem = { key: string; to: string; label: string; no?: number; title: string; locked?: boolean; done?: boolean };
function flowNav(artifacts: ArtifactOverview[], consulting: boolean): NavItem[] {
const stages = stageProgress(artifacts);
return [
{ key: "overview", to: "overview", label: "总览", title: "总览:现在在第几步、下一步做什么" },
{ key: "materials", to: "materials", label: "资料", no: 1, title: "上传资料:企业简介、组织架构、上年经营复盘,确认企业画像" },
...STAGES.map((stage, i): NavItem => {
const p = stages.find((x) => x.stage === stage.key);
return {
key: stage.key,
to: `workspace/${encodeURIComponent(stageEntryCode(artifacts, stage.key))}`,
label: stage.flow,
no: i + 2,
title: `${stage.flow}(书中:${stage.name})${p && !p.open ? ":上一步全部定版后开启" : ""}`,
locked: !!p && !p.open,
done: !!p?.complete,
};
}),
{ key: "results", to: "results", label: "成果", no: 5, title: "成果:每张表的状态与版本,下载计划书和各表" },
...(consulting ? [{ key: "expert", to: "expert", label: "专家咨询", title: "专家咨询:方法流程解决不了的关键分歧,由人工咨询师受理" }] : []),
];
}
const ENTERPRISE_ROLES = ["ent_lead", "dept_head", "member", "readonly"];
type TeamMember = { userId: number; nickname: string; projectRole: string };
@@ -78,6 +96,7 @@ function ProjectTopbar({
projectRole,
isConsultAdmin,
team,
artifacts,
setSlotActions,
}: {
projectId: number;
@@ -85,10 +104,16 @@ function ProjectTopbar({
projectRole: ProjectRole | null | undefined;
isConsultAdmin: boolean;
team: TeamMember[];
artifacts: ArtifactOverview[];
setSlotActions?: (el: HTMLElement | null) => void;
}) {
const section = useLocation().pathname.split("/")[3] || "overview";
const nav = projectRole && ENTERPRISE_ROLES.includes(projectRole) && !isConsultAdmin ? ENTERPRISE_NAV : FULL_NAV;
const parts = useLocation().pathname.split("/");
const section = parts[3] || "overview";
const consulting = !(projectRole && ENTERPRISE_ROLES.includes(projectRole) && !isConsultAdmin);
const nav = flowNav(artifacts, consulting);
// 在填写页时,亮起当前这张表所在的阶段。
const code = section === "workspace" && parts[4] ? decodeURIComponent(parts[4]) : null;
const activeKey = code ? ARTIFACTS.find((a) => a.code === code)?.stage ?? null : section;
return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b-2 border-gold px-3 text-band-foreground md:px-4" style={{ background: "var(--band)" }}>
<Link to="/projects" aria-label="返回我的项目" title="我的项目" className="flex shrink-0 items-center gap-2 rounded-lg pr-1 hover:bg-white/10">
@@ -98,21 +123,35 @@ function ProjectTopbar({
<span aria-hidden className="hidden h-5 w-px bg-white/25 xl:block" />
<ProjectSwitcher projectId={projectId} projects={projects} section={section} />
<nav aria-label="项目导航" className="ml-2 flex h-full min-w-0 items-stretch overflow-x-auto">
{nav.map((item) => (
<NavLink
key={item.to}
to={item.to}
title={item.title}
className={({ isActive }) =>
cn(
"flex shrink-0 items-center border-b-2 px-3 pt-0.5 text-sm",
isActive ? "border-gold font-bold text-band-foreground" : "border-transparent text-band-muted hover:text-band-foreground",
)
}
>
{item.label}
</NavLink>
))}
{nav.map((item) => {
const active = item.key === activeKey;
return (
<Link
key={item.key}
to={item.to}
title={item.title}
aria-current={active ? "page" : undefined}
className={cn(
"flex shrink-0 items-center gap-1.5 border-b-2 px-2.5 pt-0.5 text-sm",
active ? "border-gold font-bold text-band-foreground" : "border-transparent text-band-muted hover:text-band-foreground",
)}
>
{item.no ? (
<span
aria-hidden
className={cn(
"flex size-[18px] items-center justify-center rounded-full border text-[11px] font-bold",
item.done ? "border-gold bg-gold text-[#0c3d52]" : active ? "border-gold text-gold" : "border-white/40",
)}
>
{item.done ? <Check className="size-3" /> : item.no}
</span>
) : null}
{item.label}
{item.locked ? <Lock className="size-3 opacity-70" aria-label="待开启" /> : null}
</Link>
);
})}
</nav>
<div className="ml-auto flex shrink-0 items-center gap-2.5">
{projectRole ? <span className="hidden text-xs text-band-muted lg:inline">{PROJECT_ROLE_LABELS[projectRole]}</span> : null}
@@ -153,6 +192,7 @@ export default function ProjectLayout() {
projectRole={overview.data?.projectRole ?? project?.projectRole}
isConsultAdmin={!!overview.data?.isConsultAdmin}
team={overview.data?.team ?? []}
artifacts={overview.data?.artifacts ?? []}
setSlotActions={inWorkspace ? setSlotActions : undefined}
/>
);
+56 -3
View File
@@ -1,4 +1,4 @@
import { ArrowRight, History, Lock, Sparkles } from "lucide-react";
import { ArrowRight, Eye, History, Lock, Sparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, Navigate, useParams, useSearchParams } from "react-router";
@@ -39,6 +39,7 @@ import { useAddComment } from "@/lib/dingze/comments-api";
import { type Command, type CommandMeta, appendCommand, buildCommand, preview, reapplyCommand, revertCommand, toSaved } from "@/lib/dingze/edit-log";
import { getVersionPayload } from "@/lib/dingze/records-api";
import { nextArtifact } from "@/lib/dingze/next-step";
import { hasSample, samplePath } from "@/lib/dingze/sample";
import { useArtifactHistory, usePlanBookSources, useRecordExport } from "@/lib/dingze/records-queries";
import { formatTime } from "@/lib/dingze/records";
import { locateAnchor } from "@/lib/dingze/locate";
@@ -54,7 +55,7 @@ import { PROPOSE_TOOL } from "@/lib/dingze/coach";
import { errorMessage } from "@/lib/dingze/errors";
import { ArtifactActions, DissentList } from "./components/artifact-actions";
import { CoachPanel } from "./components/coach-panel";
import { CoachPanel, askCoach } from "./components/coach-panel";
import { DiagramExportButton } from "./components/diagram-export";
import { CharterDocxButton } from "./components/charter-export";
import { ExportButton } from "./components/export-button";
@@ -277,6 +278,9 @@ function Workspace({ code }: { code: string }) {
};
const status = artifact?.status ?? "not_started";
const unlocked = detail.data?.unlock.unlocked ?? false;
// 空表上的“让数字咨询师起草”提示,本张表关掉后不再出现(换表重新出现)。
const [draftHintOff, setDraftHintOff] = useState(false);
useEffect(() => setDraftHintOff(false), [code]);
const canEditRole =
!!role &&
(ENTERPRISE_ROLES.includes(role) || CONSULTANT_ROLES.includes(role));
@@ -788,6 +792,18 @@ function Workspace({ code }: { code: string }) {
/>
);
const bookOpen = panelOpen && asideTab === "book";
// 看示例:新标签页打开虚构企业瀚川燃气的同一张表,对照着填(只读)。
const sampleLink = hasSample(code) ? (
<Link
to={samplePath(code)}
target="_blank"
rel="noopener"
title="新窗口打开示例企业(瀚川燃气,虚构)的同一张表,对照着填"
className="mx-1 flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Eye className="size-3.5" /> <span className="font-semibold">看示例</span>
</Link>
) : null;
const methodButton = methodLine ? <MethodButton text={methodLine} active={bookOpen} onClick={() => (bookOpen ? setAsideTab("coach") : openAside.current("book"))} /> : null;
// 交出去以后的下一张:一个小链接放在状态行,不再占编辑区一整行(2026-10-16)。
const handedOff = ["step_done", "in_review", "pending_confirm", "locked"].includes(status);
@@ -952,12 +968,13 @@ function Workspace({ code }: { code: string }) {
session.setSelecting(!selecting);
}}
onShortcuts={() => setShortcutsOpen(true)}
method={methodButton}
method={<>{sampleLink}{methodButton}</>}
setHowToSlot={setHowToSlot}
/>
</div>
) : (
<div className="-mx-4 flex min-h-10 items-center justify-end gap-1 border-t px-3 md:-mx-5">
{sampleLink}
{methodButton}
<span ref={setHowToSlot} className="contents" />
</div>
@@ -1140,6 +1157,16 @@ function Workspace({ code }: { code: string }) {
</span>
</div>
) : null}
{status === "not_started" && serverRev === 0 && !readOnly && !previewDraft && !viewRev && edit.log.length === 0 && !draftHintOff ? (
<DraftHint
code={code}
onDraft={() => {
openAside.current("coach");
askCoach(`请结合我们企业的资料、画像和已定版的上游表,直接起草《${def.plain}》整张表,用修改卡给我,我确认后再写入。缺的信息先用【待补】标出来。`);
}}
onDismiss={() => setDraftHintOff(true)}
/>
) : null}
{EDITABLE_CODES.has(code) ? (
<div data-dz-readonly={(readOnly && !previewDraft) || viewRev ? "" : undefined} className="contents">
<RegionErrorBoundary
@@ -1310,3 +1337,29 @@ function Workspace({ code }: { code: string }) {
</EditSessionContext.Provider>
);
}
/** 空表提示:一张新表最省力的开头是让数字咨询师按资料起草,再改;也可以先看示例。 */
function DraftHint({ code, onDraft, onDismiss }: { code: string; onDraft: () => void; onDismiss: () => void }) {
return (
<div className="mb-3 flex flex-wrap items-center gap-3 rounded-xl border border-primary/40 bg-primary/5 px-4 py-3 text-sm">
<Sparkles className="size-4 shrink-0 text-primary" />
<span className="min-w-0 flex-1">
<span className="font-semibold">这张表还是空的。</span> 不用一格一格填:让数字咨询师按你上传的资料先起草一版,你再改。它只给修改卡,确认后才写进表里。
</span>
<span className="flex shrink-0 gap-2">
<Button size="sm" onClick={onDraft}>
<Sparkles /> 按资料起草这张表
</Button>
{hasSample(code) ? (
<Button size="sm" variant="outline" nativeButton={false} render={<Link to={samplePath(code)} target="_blank" rel="noopener" />}>
<Eye /> 看示例
</Button>
) : null}
<Button size="sm" variant="ghost" onClick={onDismiss} aria-label="关闭提示">
自己填
</Button>
</span>
</div>
);
}
+13 -2
View File
@@ -1,4 +1,5 @@
import { CheckCircle2, Ticket } from "lucide-react";
import { useLogin } from "@refinedev/core";
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router";
@@ -30,6 +31,7 @@ export function InviteRegister() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<{ enterprise: string; username: string } | null>(null);
const { mutate: login } = useLogin<{ account: string; password: string; redirectTo: string }>();
const set = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [key]: e.target.value }));
const check = async (value = code) => {
@@ -69,6 +71,14 @@ export function InviteRegister() {
email: form.email.trim() || undefined,
remark: form.remark.trim() || undefined,
});
if (r.status === "approved") {
// 免审核的码:账号已开好,直接登录进项目。
login(
{ account: form.username.trim(), password: form.password, redirectTo: r.projectId ? `/projects/${r.projectId}/overview` : "/projects" },
{ onError: () => setError("账号已开好,但自动登录没成功。请用登录名和刚设的密码登录。") },
);
return;
}
setDone({ enterprise: r.enterprise, username: form.username.trim() });
} catch (e) {
setError(errorMessage(e));
@@ -92,7 +102,7 @@ export function InviteRegister() {
}
return (
<AuthLayout title="凭邀请码加入" description="邀请码由本企业管理员或平台运营发放。提交后需审核,通过才开通账号。" footer={toLogin}>
<AuthLayout title="凭邀请码加入" description={scope?.autoApprove ? "填好就能进,不用等审核。" : "邀请码由本企业管理员或平台运营发放。"} footer={toLogin}>
<div className="flex flex-col gap-5">
<form
className="flex items-end gap-2"
@@ -130,6 +140,7 @@ export function InviteRegister() {
<div className="font-medium">加入 {scope.enterprise}</div>
<div className="text-muted-foreground">
{[scope.project && `项目「${scope.project}」`, scope.role, scope.dept].filter(Boolean).join(" · ") || "加入企业,项目由管理员再分派"}
{scope.autoApprove ? "" : " · 提交后需审核"}
</div>
</div>
</div>
@@ -171,7 +182,7 @@ export function InviteRegister() {
</FieldGroup>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" className="w-full" disabled={submitting || !!pwProblem || !!mismatch}>
{submitting ? "提交中…" : "提交申请"}
{submitting ? "提交中…" : scope.autoApprove ? "加入" : "提交申请"}
</Button>
</form>
</>
+97
View File
@@ -0,0 +1,97 @@
import { Eye } from "lucide-react";
import { Link, Navigate, useParams } from "react-router";
import { STAGES, getArtifactDef } from "@dingze/shared";
import { RegionErrorBoundary } from "@/components/dingze/region-error-boundary";
import { SAMPLE, SAMPLE_CODES, SAMPLE_ORG_UNITS, hasSample, samplePath } from "@/lib/dingze/sample";
import { samplePayloads } from "@/lib/dingze/sample-data";
import { cn } from "@/lib/utils";
import { ArtifactEditor } from "../projects/editors/artifact-editor";
const noop = () => {};
/** One sample table, read-only. Exported for the component test. */
export function SampleTable({ code }: { code: string }) {
const payloads = samplePayloads();
return (
<ArtifactEditor
code={code}
value={payloads[code]}
onChange={noop}
readOnly
issues={[]}
upstream={payloads}
projectYear={SAMPLE.year}
keyProjectLevel={SAMPLE.keyProjectLevel}
orgUnits={SAMPLE_ORG_UNITS}
team={[]}
scheduleScale={SAMPLE.scheduleScale}
enterprise={SAMPLE.enterprise}
/>
);
}
/**
* 示例项目:虚构企业瀚川燃气 2026 年从战略解码到计划制定的完整一套表,只能看。
* 填写页的“看示例”按钮打开同一张表。
*/
export default function SamplePage() {
const { code: raw } = useParams();
const code = raw ? decodeURIComponent(raw) : SAMPLE_CODES[0];
if (!hasSample(code)) return <Navigate to={samplePath()} replace />;
const def = getArtifactDef(code);
const stage = STAGES.find((s) => s.key === def.stage)!;
return (
<div className="flex flex-col gap-5">
<div>
<h1 className="font-heading text-2xl font-bold text-brand">示例:{SAMPLE.shortName}</h1>
<p className="mt-1 text-sm text-muted-foreground">
虚构企业「{SAMPLE.enterprise}」{SAMPLE.project}的完整成果,只能看不能改。填自己的表时,可以点表上的“看示例”对照。
</p>
</div>
<div className="flex flex-col gap-5 lg:flex-row">
<nav aria-label="示例的表" className="flex shrink-0 flex-col gap-4 lg:w-60">
{STAGES.map((s, i) => (
<div key={s.key} className="flex flex-col gap-1">
<div className="px-2 text-xs font-bold text-muted-foreground">
第 {i + 2} 步 · {s.flow}
</div>
{SAMPLE_CODES.filter((c) => getArtifactDef(c).stage === s.key).map((c) => {
const d = getArtifactDef(c);
return (
<Link
key={c}
to={samplePath(c)}
aria-current={c === code ? "page" : undefined}
className={cn("rounded-md px-2 py-1.5 text-sm hover:bg-muted", c === code && "bg-accent font-semibold")}
>
{d.plain} <span className="text-xs font-normal text-muted-foreground">{d.specId}</span>
</Link>
);
})}
</div>
))}
</nav>
<section className="min-w-0 flex-1 rounded-xl border bg-card p-4">
<div className="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b pb-3">
<h2 className="font-heading text-xl font-bold">{def.plain}</h2>
<span className="text-xs text-muted-foreground">
{stage.flow} · 书中:{def.specId} {def.name}({def.bookRef})
</span>
<span className="ml-auto flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<Eye className="size-3.5" /> 示例 · 只读
</span>
</div>
<RegionErrorBoundary title="这张示例表没能显示" description="可以先看别的表。" resetKeys={[code]}>
<SampleTable code={code} />
</RegionErrorBoundary>
</section>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useLogin } from "@refinedev/core";
import { useState } from "react";
import { Link } from "react-router";
import { AuthLayout } from "@/components/auth/auth-layout";
import { InputPassword } from "@/components/auth/input-password";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { errorMessage } from "@/lib/dingze/errors";
import { passwordProblem, trialApi } from "@/lib/dingze/invite-api";
// 免费试用注册:填四项、勾一下,马上开好试用企业和项目,自动登录进第 1 步“上传资料”。
const toLogin = (
<Link to="/login" className="text-muted-foreground hover:text-foreground hover:underline hover:underline-offset-4">
已有账号?去登录
</Link>
);
export function TrialSignUpPage() {
const [form, setForm] = useState({ enterpriseName: "", nickname: "", phone: "", password: "", confirm: "", website: "" });
const [agree, setAgree] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { mutate: login } = useLogin<{ account: string; password: string; redirectTo: string }>();
const set = (key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [key]: e.target.value }));
const pwProblem = form.password ? passwordProblem(form.password) : null;
const mismatch = form.confirm && form.confirm !== form.password ? "两次输入的密码不一样" : null;
const phoneProblem = form.phone && !/^1\d{10}$/.test(form.phone.trim()) ? "请填写 11 位手机号" : null;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (pwProblem || mismatch || phoneProblem || !agree) return;
setSubmitting(true);
setError(null);
try {
const r = await trialApi.signUp({
enterpriseName: form.enterpriseName.trim(),
nickname: form.nickname.trim(),
phone: form.phone.trim(),
password: form.password,
agree,
website: form.website || undefined,
});
// 注册成功直接登录,进项目的第 1 步。
login(
{ account: r.username, password: form.password, redirectTo: `/projects/${r.projectId}/materials` },
{ onError: () => setError("账号已开好,但自动登录没成功。请用手机号和刚设的密码登录。") },
);
} catch (e) {
setError(errorMessage(e));
setSubmitting(false);
}
};
return (
<AuthLayout title="免费试用" description="填好下面几项就能开始,不用等审核。" footer={toLogin}>
<form onSubmit={submit} className="flex flex-col gap-5">
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="trial-enterprise">企业名称</FieldLabel>
<Input id="trial-enterprise" value={form.enterpriseName} onChange={set("enterpriseName")} minLength={2} maxLength={60} autoFocus required />
</Field>
<Field>
<FieldLabel htmlFor="trial-nickname">你的姓名</FieldLabel>
<Input id="trial-nickname" value={form.nickname} onChange={set("nickname")} maxLength={50} required />
</Field>
<Field data-invalid={!!phoneProblem || undefined}>
<FieldLabel htmlFor="trial-phone">手机号</FieldLabel>
<Input id="trial-phone" value={form.phone} onChange={set("phone")} inputMode="tel" autoComplete="tel" maxLength={11} required />
{phoneProblem ? <FieldError>{phoneProblem}</FieldError> : <FieldDescription>以后用手机号登录。</FieldDescription>}
</Field>
<Field data-invalid={!!pwProblem || undefined}>
<FieldLabel htmlFor="trial-password">密码</FieldLabel>
<InputPassword id="trial-password" value={form.password} onChange={set("password")} autoComplete="new-password" required />
{pwProblem ? <FieldError>密码{pwProblem}</FieldError> : <FieldDescription>至少 8 位,字母和数字都要有。</FieldDescription>}
</Field>
<Field data-invalid={!!mismatch || undefined}>
<FieldLabel htmlFor="trial-confirm">再输一次密码</FieldLabel>
<InputPassword id="trial-confirm" value={form.confirm} onChange={set("confirm")} autoComplete="new-password" required />
{mismatch ? <FieldError>{mismatch}</FieldError> : null}
</Field>
{/* 蜜罐:人看不到也不会填。 */}
<input type="text" name="website" value={form.website} onChange={set("website")} tabIndex={-1} autoComplete="off" aria-hidden className="hidden" />
<label className="flex items-start gap-2 text-sm leading-relaxed">
<Checkbox checked={agree} onCheckedChange={(v) => setAgree(v === true)} className="mt-0.5" />
<span className="text-muted-foreground">
我知道这是试用版:请用脱敏资料;资料和对话会发给数字咨询师背后的大模型服务处理。
</span>
</label>
</FieldGroup>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" className="w-full" disabled={submitting || !agree || !!pwProblem || !!mismatch || !!phoneProblem}>
{submitting ? "正在开通…" : "开始试用"}
</Button>
</form>
</AuthLayout>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { ArrowRight, FileSpreadsheet, FileText, Target } from "lucide-react";
import { Link } from "react-router";
import { BrandWordmark } from "@/components/app-shell/brand";
import { CUSTOMER_FLOW } from "@/components/auth/auth-layout";
import { Button } from "@/components/ui/button";
// 未登录时的首页:一句话说清能做什么,五步流程,免费试用 / 登录。
const OUTCOMES = [
{ icon: Target, title: "战略一页纸和战略地图", text: "战略说清楚,指标能衡量" },
{ icon: FileSpreadsheet, title: "部门计分卡和关键项目", text: "年度目标分到每个部门、每个项目" },
{ icon: FileText, title: "年度经营计划书", text: "公司和部门两级,可下载 Word" },
];
export function WelcomePage() {
return (
<div className="min-h-svh bg-background">
<header className="mx-auto flex max-w-6xl items-center gap-3 px-4 py-4 md:px-6">
<BrandWordmark />
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" nativeButton={false} render={<Link to="/login" />}>
登录
</Button>
<Button nativeButton={false} render={<Link to="/trial" />}>免费试用</Button>
</div>
</header>
<section className="bg-brand text-brand-foreground">
<div className="mx-auto max-w-6xl px-4 py-14 md:px-6 md:py-20">
<div className="text-xs font-semibold tracking-[0.3em] text-gold">企业战略咨询平台</div>
<h1 className="mt-4 max-w-3xl font-heading text-4xl font-bold leading-tight md:text-5xl">
把战略落到每个部门、每个人
</h1>
<p className="mt-5 max-w-2xl text-base leading-7 text-brand-foreground/80">
按《自驱战略落地》的方法,从上传资料开始,一步步做出战略解码、目标分解和年度经营计划。每一步都有数字咨询师陪你填。
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button size="lg" className="bg-gold text-brand hover:bg-gold/90" nativeButton={false} render={<Link to="/trial" />}>
免费试用 <ArrowRight />
</Button>
<Button size="lg" variant="outline" className="border-white/40 bg-transparent text-brand-foreground hover:bg-white/10" nativeButton={false} render={<Link to="/login" />}>
已有账号,登录
</Button>
</div>
</div>
</section>
<section className="mx-auto max-w-6xl px-4 py-12 md:px-6">
<h2 className="font-heading text-2xl font-bold text-brand">五步走完</h2>
<ol className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{CUSTOMER_FLOW.map((step, i) => (
<li key={step.name} className="flex flex-col gap-2 rounded-xl border bg-card p-4">
<span className="flex size-8 items-center justify-center rounded-lg bg-gold font-heading font-bold text-brand">{i + 1}</span>
<span className="font-heading text-lg font-bold">{step.name}</span>
<span className="text-sm text-muted-foreground">{step.hint}</span>
</li>
))}
</ol>
</section>
<section className="mx-auto max-w-6xl px-4 pb-16 md:px-6">
<h2 className="font-heading text-2xl font-bold text-brand">做完你会得到</h2>
<ul className="mt-6 grid gap-3 md:grid-cols-3">
{OUTCOMES.map(({ icon: Icon, title, text }) => (
<li key={title} className="flex gap-3 rounded-xl border bg-card p-4">
<Icon className="mt-0.5 size-5 shrink-0 text-brand" />
<span>
<span className="block font-semibold">{title}</span>
<span className="text-sm text-muted-foreground">{text}</span>
</span>
</li>
))}
</ul>
<p className="mt-6 text-sm text-muted-foreground">
不知道怎么填?注册后可以看一家虚构企业(瀚川燃气)的完整示例,每张表都能对照着看。
</p>
</section>
</div>
);
}
+19 -1
View File
@@ -1,4 +1,4 @@
import { Activity, BookOpenCheck, Building, Building2, ClipboardList, FolderKanban, MessagesSquare, Settings2, Tent, UsersRound } from "lucide-react";
import { Activity, BookOpenCheck, Building, Building2, ClipboardList, Eye, FolderKanban, LifeBuoy, MessagesSquare, Settings2, Tent, UsersRound } from "lucide-react";
import { Navigate, Outlet } from "react-router";
import { defineAppRoutes } from "@nocobase/portal-sdk/routing";
@@ -44,6 +44,24 @@ export const appRoutes = defineAppRoutes([
{ name: "project.expert", path: "expert", lazy: () => import("@/pages/projects/expert") },
],
},
{
// 示例项目:虚构企业的完整一套表,只读,填表时对照着看。
name: "sample",
path: "/sample",
lazy: () => import("@/pages/sample"),
resource: {
meta: { label: "示例项目", priority: 1.5, icon: <Eye />, description: "虚构企业瀚川燃气的完整成果,只读,填表时对照着看。" },
},
},
{ name: "sample.table", path: "/sample/:code", lazy: () => import("@/pages/sample") },
{
name: "help",
path: "/help",
lazy: () => import("@/pages/help"),
resource: {
meta: { label: "使用指南", priority: 6, icon: <LifeBuoy />, description: "五步走完的简明说明。" },
},
},
{
name: "board",
path: "/board",
+18
View File
@@ -0,0 +1,18 @@
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { SAMPLE_CODES } from "@/lib/dingze/sample";
import { SampleTable } from "@/pages/sample";
// 示例页不经过填写页的编辑会话,每张示例表都要能单独只读显示。
describe("sample tables", () => {
it.each(SAMPLE_CODES)("renders %s read-only without the workspace", (code) => {
const { container } = render(
<MemoryRouter>
<SampleTable code={code} />
</MemoryRouter>,
);
expect(container.textContent?.length ?? 0).toBeGreaterThan(20);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { ARTIFACTS, type ArtifactStatus } from "@dingze/shared";
import type { ArtifactOverview } from "@/lib/dingze/api";
import { flowSteps } from "@/lib/dingze/flow";
import { stageEntryCode, stageProgress } from "@/lib/dingze/progress";
import { SAMPLE_CODES } from "@/lib/dingze/sample";
import { samplePayloads } from "@/lib/dingze/sample-data";
const make = (overrides: Record<string, Partial<ArtifactOverview>> = {}): ArtifactOverview[] =>
ARTIFACTS.map((a) => ({
code: a.code,
status: "not_started" as ArtifactStatus,
stale: false,
staleReason: null,
exception: false,
currentRev: 0,
updatedAt: null,
unlock: { unlocked: a.stage === "strategy" && (a.code === "S1-01" || a.priority === "method") },
...overrides[a.code],
}));
const lockStage = (stage: string) =>
Object.fromEntries(ARTIFACTS.filter((a) => a.stage === stage).map((a) => [a.code, { status: "locked" as ArtifactStatus, unlock: { unlocked: true } }]));
const flow = (artifacts: ArtifactOverview[], materialsReady = false) =>
flowSteps({ stages: stageProgress(artifacts), materialsReady, materialCount: materialsReady ? 2 : 0, started: artifacts.some((a) => a.status !== "not_started") });
describe("customer flow", () => {
it("starts a fresh project at 上传资料, with 战略解码 open and the rest locked", () => {
const steps = flow(make());
expect(steps.map((s) => s.label)).toEqual(["上传资料", "战略解码", "目标分解", "计划制定", "下载成果"]);
expect(steps.map((s) => s.state)).toEqual(["current", "todo", "locked", "locked", "locked"]);
});
it("lets the customer skip materials once they start filling tables", () => {
const steps = flow(make({ "S1-01": { status: "in_progress", unlock: { unlocked: true } } }));
expect(steps[0].state).toBe("todo");
expect(steps[1].state).toBe("current");
});
it("moves to 目标分解 once every 战略解码 gate table is 定版", () => {
const artifacts = make({ ...lockStage("strategy"), "S2-03": { unlock: { unlocked: true } } });
const steps = flow(artifacts, true);
expect(steps.map((s) => s.state)).toEqual(["done", "done", "current", "locked", "locked"]);
});
it("ends at 下载成果 when all three stages are 定版", () => {
const steps = flow(make({ ...lockStage("strategy"), ...lockStage("goal"), ...lockStage("action") }), true);
expect(steps.map((s) => s.state)).toEqual(["done", "done", "done", "done", "current"]);
});
it("sends a stage tab to the first open table that is not 定版 yet", () => {
expect(stageEntryCode(make(), "strategy")).toBe("S1-01");
expect(stageEntryCode(make({ "S1-01": { status: "locked", unlock: { unlocked: true } }, "S1-02": { unlock: { unlocked: true } } }), "strategy")).toBe("S1-02");
// Nothing open yet: the stage's first table, which shows why it is locked.
expect(stageEntryCode(make(), "goal")).toBe(ARTIFACTS.find((a) => a.stage === "goal" && a.priority !== "method")!.code);
});
});
describe("sample project", () => {
it("lists exactly the tables the demo seed has", () => {
expect(Object.keys(samplePayloads()).sort()).toEqual([...SAMPLE_CODES].sort());
});
});