mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
Merge remote-tracking branch 'origin/main' into claude/project-thread-liggvu
This commit is contained in:
+3
-1
@@ -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 员工、权限与隔离 |
|
||||
|
||||
@@ -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
@@ -9,7 +9,7 @@
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
| 地址 | https://ziqu.aihupo.cn/x/dingze/ |
|
||||
| 登录 | 账号 + 密码。不开放公开注册,没有钉钉登录 |
|
||||
| 登录 | 账号 + 密码。客户可在首页「免费试用」自助注册(2026-11 起,开通前需在 dev 实测);没有钉钉登录 |
|
||||
| 浏览器 | Chrome 或 Edge 最新版,屏幕宽度 1366 以上;手机只适合查看 |
|
||||
| 数据 | 两家虚构企业的演示数据:瀚川燃气(城市燃气,大型)、启衡能源(储能与充换电,中小企业),另有三家训练营企业 |
|
||||
| 数字咨询师 | 接 DeepSeek(Flash)。回复一般 10~30 秒;只给修改建议卡,点“确认”后才写入 |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 运营与咨询师速查(我方版)
|
||||
|
||||
一页纸,够日常用。细节查[详细参考](user-guide.md)。
|
||||
|
||||
## 每天看一眼
|
||||
|
||||
- 「运营管理 › 运营看板」:哪些项目卡住了。
|
||||
- 右上角铃铛:新试用企业注册、待复核的表。
|
||||
|
||||
## 新客户
|
||||
|
||||
- **客户自己注册的**:在「企业与开通」里带「自助试用」标签。系统已开好企业、管理员账号(手机号登录)和一个自助项目,不用我们做什么。
|
||||
- **要转陪跑**:打开企业 ›「项目」,编辑项目,服务方式改成「陪跑」,分派主咨询师。
|
||||
- **乱填或可疑的注册**:打开企业,点「停用企业」。
|
||||
- **正式客户由我们开**:「企业与开通 › 开通企业」,填企业、管理员和项目。初始密码只显示一次,单独发给客户,不要发群里。
|
||||
- **训练营**:用「训练营批次」批量开企业,再点「一键发团队码」。每家企业得到一个码(20 人、60 天),转给企业对接人发给参训人员,凭码注册直接进项目,不用审核。
|
||||
- **企业内训几十人**:在企业页「邀请注册」新建一个邀请码(默认免审核,名额填够),发给企业对接人即可。想把关就把「免审核」勾掉。
|
||||
|
||||
## 复核一张表(陪跑项目)
|
||||
|
||||
1. 打开「咨询师工作台」,点待复核的表。
|
||||
2. 看校验条有没有红色问题,看批注。
|
||||
3. 没问题点「复核通过」;有问题点「退回修改」,写清要改什么。
|
||||
|
||||
## 已定版的表要改
|
||||
|
||||
- 主咨询师或企业项目负责人点「解锁重开」,写原因。
|
||||
- 下游的表会提示「上游已变更」,由企业核对后点「已核对」。
|
||||
|
||||
## 客户常问
|
||||
|
||||
| 问题 | 怎么答 |
|
||||
|---|---|
|
||||
| 忘记密码 | 「企业与开通」里找到人,点「重置密码」。新密码只显示一次,单独发给他。 |
|
||||
| 表打不开 | 上一张还没点「本步完成」。 |
|
||||
| 不知道怎么填 | 点表上的「看示例」,或左侧菜单「示例项目」;也可以直接问数字咨询师。 |
|
||||
| 能不能传真实资料 | 试用期请用脱敏资料。资料和对话会发给大模型服务。 |
|
||||
| 数字咨询师改了我的表? | 不会。它只给修改卡,客户点确认才写入。 |
|
||||
|
||||
## 给客户的说明
|
||||
|
||||
直接发[三分钟上手](quick-start.md),或让客户看门户左侧菜单的「使用指南」。
|
||||
@@ -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
@@ -1,4 +1,6 @@
|
||||
# 操作手册
|
||||
# 详细参考(原操作手册)
|
||||
|
||||
> 日常用这两份一页纸就够:客户看[三分钟上手](quick-start.md),我们看[运营与咨询师速查](ops-quick-ref.md)。本文留作查细节,不再整份发给客户。
|
||||
|
||||
「自驱战略 · 定三责」平台把《自驱战略落地》书中的三张管理检查表变成一步一步的在线工作:**定战略责 → 定目标责 → 定行动责**。每一步有一张成果表,企业填写、咨询师复核、企业确认后定版。下游表引用上游的定版内容;上游一改,下游会提示“上游已变更”。
|
||||
|
||||
@@ -72,7 +74,7 @@
|
||||
|
||||
### 6. 登录与密码
|
||||
|
||||
- 平台**不开放公开注册**。账号由运营或本企业管理员开通,也可以凭邀请码申请(见第二章第 1 节)。
|
||||
- 客户可以在首页点「免费试用」自助注册,自动开好试用企业和自助项目。其他账号由运营或本企业管理员开通,也可以凭邀请码申请(见第二章第 1 节)。
|
||||
- 忘记密码:点登录页的“忘记密码”,按提示联系本企业管理员或运营重置。
|
||||
- 打不开某个项目时,页面上有“回我的项目”。
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 秒,稍等一下。",
|
||||
"想请人工咨询师陪跑,或忘记密码:联系我们。",
|
||||
],
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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},全部定版后开启下一阶段
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 时对照着看。组织之外的责任者可以加自定义列;定版后组织再变,这里只提示、不自动改。`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user