Commit 15b347e3 by luoqi

feat(主管工作台): 独立路由 /supervisor —— 我分的批次 / 团队现在什么状态 / 分一批新的

🔴 独立路由推翻了 T16 的后半句(2026-08-04 评审定,doctrine 已改写并保留病史):
需求方三次讲同一件事「场景和思路不应该去混」。T16 没被推翻的那半句仍作数
(主管也要打电话),所以头部常驻「客服执行页 」,两个页面双向。

后端四处聚合:
· workload —— 名册走 T10 同源;「当前在手」是此刻,超期/完成/退回/没动都在窗口内
· 窗口内超期走**账本**:回收器每 10 分钟扫,"当前超期"结构上几乎永远是 0
  (实测账本 378 条 vs 当前 0)。归属回捞到到期前最后一次 assign,
  并上"仍在手且窗口内到期"那一小撮,按 plan_id 去重
· agentStats[].done 改执行口径(有 plan_executions 才算):池子口径的 resolved
  是引擎判定需求没了,客服一根手指没动也会被算进他的「已处置」
· 列表补 booked/handled + 游标分页( 不是 offset)

界面:
· 落地规则双向 —— 有派单权的 /plans→/supervisor(?exec=1 是明确导航意图的出口);
  没派单权的 /supervisor→/plans。原来只有单向,客服落到工作台是**纯白页**
  (Can 无 fallback 渲染 null),不报错也没出口
· 批次跟踪:滚动位置翻页( IntersectionObserver 在零高度 sentinel 上不触发)、
  inFlight ref 防重入、IDLE_HEAVY=0.2 一个常量两处共用
· 团队状态:超期上琥珀(好事有色坏事没色,眼睛只会被绿色勾住)
· 分一批新的两版并存;传送门改 transform:scale 的小圆
  (clip-path 的 at 按 reference box 算,祖先一有 transform/filter 就整体偏掉、
   而且不报错;且 clip-path 不上合成层,那半秒每帧重画整屏)

通话记录:
· plan_executions.notes 此前**全线读不到** —— 接口一直在,前端没调、MCP 没开工具
· 逐条明细 + 纪要 + 客服勾的子选项(放弃原因 / 判断不对的治疗 / 约的回访日 / 渠道)
   只回 outcome 等于把客服填的一半烂在库里
· 删掉 outcomes.note:它把给人看的数和给模型的指令焊在一根 string 里,
  而工具说明还写着"照抄最稳" → 模型把「不要算成功率、不要画图」原样贴进了对话框

测试:$queryRaw 的桩改成**认 SQL 不认调用次序**(加第三条原生查询时次序错位,
17 条一起红,报的还是 toISOString 这种跟真因无关的错)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b9603d77
Pipeline #3548 failed in 0 seconds
...@@ -469,7 +469,22 @@ export class McpServerFactory { ...@@ -469,7 +469,22 @@ export class McpServerFactory {
'\n⚠️ `outcomes.noOutcome`(一次结果都没有)**必须单独报出来**,' + '\n⚠️ `outcomes.noOutcome`(一次结果都没有)**必须单独报出来**,' +
'⛔ 不许算进"不成功" —— 那不是效果差,是**根本没做/没记**。' + '⛔ 不许算进"不成功" —— 那不是效果差,是**根本没做/没记**。' +
'\n⚠️ `outcomes.success` 含**约定下次回访**(约到下次也算有效推进),' + '\n⚠️ `outcomes.success` 含**约定下次回访**(约到下次也算有效推进),' +
'⛔ 别说成"成交/转化了这么多"。照抄 `outcomes.note` 最稳。', '⛔ 别说成"成交/转化了这么多"。' +
// 🔴 这里原来还有一句「照抄 `outcomes.note` 最稳」——2026-08-08 连同那个字段一起删了:
// note 里混着给模型的指令(「不要算成功率、不要画图」),模型照抄就把内部指令
// 原样贴进了主管的对话框。⛔ 别再造这种"成品句子"字段,护栏就写在本说明里。
'\n⚠️ 上面这些数**用你自己的话讲**,⛔ 别整段照搬工具返回的字符串。' +
'\n\n⭐ `outcomes.records` = **逐条明细 + 客服手写的电话纪要**(`notes`)。' +
'主管问"哪个患者/为什么/客服怎么说的",答案只在这里 —— 别只回聚合数。' +
'\n⚠️ `notes` 为 null = **没留纪要**(不是没打)。' +
'结果填了、纪要空着本身是信息:说明只点了个选项。' +
'\n⚠️ 引用纪要时**照原话**,⛔ 别润色成"客户表示…" —— 主管要看的就是客服当时怎么写的。' +
'\n⚠️ `recordsTruncated=true` 时必须说明"只是最近的一部分"。' +
'\n⚠️ 每条 record 里 `outcome` **只是其中一个字段**,还有客服勾的子选项:' +
'`abandonReasons`(放弃原因,只有「放弃」有)、' +
'`inaccurateTreatments`(客服说**这条召回判断错了**,调算法要看它)、' +
'`scheduledNextAt`(约的回访日)、`channel`(电话/企微/短信)。' +
'⛔ 只报 outcome 等于漏掉一半 —— "为什么放弃"的答案在 `abandonReasons` 里。',
inputSchema: { assignmentId: z.string() }, inputSchema: { assignmentId: z.string() },
}, },
async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)), async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)),
......
...@@ -16,6 +16,7 @@ import { ...@@ -16,6 +16,7 @@ import {
ListAssignmentsResponseDto, ListAssignmentsResponseDto,
AssignmentDetailResponseDto, AssignmentDetailResponseDto,
ListAgentsResponseDto, ListAgentsResponseDto,
AgentWorkloadResponseDto,
RefillProposalRequestDto, RefillProposalRequestDto,
RevokeAssignmentResponseDto, RevokeAssignmentResponseDto,
SetAssignmentBenefitRequestDto, SetAssignmentBenefitRequestDto,
...@@ -71,6 +72,26 @@ export class AssignmentController { ...@@ -71,6 +72,26 @@ export class AssignmentController {
}); });
} }
/**
* 团队现在什么状态 —— 主管工作台右栏。
*
* ⚠️ 必须声明在 `@Get(':id')` **之前**(同一控制器按声明顺序匹配),
* 放后面 'workload' 会被当成 assignmentId。
* ⚠️ 一张表混着两种时间性:在手/超期是**此刻**,完成/退回/没动在**窗口内** ——
* 响应里的 `note` 已经写好这句,界面照抄,⛔ 别自己另写一版。
*/
@Get('workload')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: AgentWorkloadResponseDto })
@ApiOperation({ summary: '团队负载:每人 在手/超期(此刻)+ 完成/退回/没动(窗口内)' })
async workload(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId: string,
@Query('days') days?: string,
) {
return this.assignments.workload(scope, clinicId, days ? Number(days) : 7);
}
@Post() @Post()
@RequirePermission(Permission.PLAN_DISPATCH) @RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 201, type: CreateAssignmentResponseDto }) @ZodResponse({ status: 201, type: CreateAssignmentResponseDto })
...@@ -100,10 +121,17 @@ export class AssignmentController { ...@@ -100,10 +121,17 @@ export class AssignmentController {
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@Query('mine') mine?: string, @Query('mine') mine?: string,
@Query('limit') limit?: string,
/// 游标 = 上一页最后一条的 createdAt(ISO)。⛔ 不是 offset —— 批次一直在新增,
/// offset 翻页会重复或漏行(见 service 里 list 的注释)
@Query('before') before?: string,
) { ) {
// mine=1 → 只看自己发起的。默认看本 scope 全部(leader 之间要能互相看见, // mine=1 → 只看自己发起的。默认看本 scope 全部(leader 之间要能互相看见,
// 否则"这个诊所这周分了多少"永远拼不出来) // 否则"这个诊所这周分了多少"永远拼不出来)
return this.assignments.list(scope, mine === '1' ? user.sub : undefined); return this.assignments.list(scope, mine === '1' ? user.sub : undefined, {
...(limit ? { limit: Number(limit) } : {}),
...(before ? { before } : {}),
});
} }
@Post(':id/revoke') @Post(':id/revoke')
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
ListAssignmentsResponseSchema, ListAssignmentsResponseSchema,
ListAgentsResponseSchema, ListAgentsResponseSchema,
RevokeAssignmentResponseSchema, RevokeAssignmentResponseSchema,
AgentWorkloadResponseSchema,
RefillProposalRequestSchema, RefillProposalRequestSchema,
SetAssignmentBenefitRequestSchema, SetAssignmentBenefitRequestSchema,
SetAssignmentBenefitResponseSchema, SetAssignmentBenefitResponseSchema,
...@@ -16,6 +17,7 @@ export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentRe ...@@ -16,6 +17,7 @@ export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentRe
export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {} export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {}
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {} export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {} export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
export class AgentWorkloadResponseDto extends createZodDto(AgentWorkloadResponseSchema) {}
export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {} export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {}
export class RefillProposalRequestDto extends createZodDto(RefillProposalRequestSchema) {} export class RefillProposalRequestDto extends createZodDto(RefillProposalRequestSchema) {}
export class SetAssignmentBenefitRequestDto extends createZodDto(SetAssignmentBenefitRequestSchema) {} export class SetAssignmentBenefitRequestDto extends createZodDto(SetAssignmentBenefitRequestSchema) {}
......
...@@ -22,6 +22,22 @@ const SCOPE = { ...@@ -22,6 +22,22 @@ const SCOPE = {
} as never; } as never;
const BATCH = 'c02e1b80-1111-4222-8333-444455556666'; const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
/**
* `$queryRaw` 的桩要**认 SQL**,⛔ 不能按调用次序分派。
*
* 原来写的是 `call === 1 ? 账本 : 成效分布` —— 2026-08-08 给 detail() 加了第三条原生查询
* (通话记录明细)之后,次序整个错位,17 条测试一起红,报的还是
* `Cannot read properties of undefined (reading 'toISOString')` 这种跟真因毫无关系的错。
* ⚠️ 改成看 SQL 里的特征词:加查询时**只要不撞词就不用动测试**。
*/
function sqlKind(q: unknown): 'ledger' | 'outcomeDist' | 'outcomeRecords' | 'other' {
const text = ((q as { strings?: string[] })?.strings ?? []).join(' ');
if (text.includes('LEFT JOIN patients')) return 'outcomeRecords';
if (text.includes('GROUP BY outcome')) return 'outcomeDist';
if (text.includes('plan_event_logs')) return 'ledger';
return 'other';
}
function makePrisma(opts: { function makePrisma(opts: {
/** 现在**还归本批**的 plan(重分走的人不在这里 —— 那正是问题所在) */ /** 现在**还归本批**的 plan(重分走的人不在这里 —— 那正是问题所在) */
plans?: Array<{ id: string; status: string; assigneeUserId: string | null; releaseReason: string | null }>; plans?: Array<{ id: string; status: string; assigneeUserId: string | null; releaseReason: string | null }>;
...@@ -34,8 +50,8 @@ function makePrisma(opts: { ...@@ -34,8 +50,8 @@ function makePrisma(opts: {
events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>; events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>;
}) { }) {
const plans = opts.plans ?? []; const plans = opts.plans ?? [];
const queryRaw = jest.fn(async () => const queryRaw = jest.fn(async (q: unknown) =>
opts.ledger sqlKind(q) === 'ledger' && opts.ledger
? [{ ? [{
assignment_id: BATCH, assignment_id: BATCH,
planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents), planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents),
...@@ -69,6 +85,9 @@ function makePrisma(opts: { ...@@ -69,6 +85,9 @@ function makePrisma(opts: {
})), })),
), ),
}, },
// 「已处置」按人拆走**执行口径**(有通话结果才算);空 = 这批没回写过结果,
// ⚠️ 那是生产常态(回写率约 11%),⛔ 别在 stub 里编几条让数字好看
planExecution: { groupBy: jest.fn(async () => []) },
planEventLog: { planEventLog: {
// ⚠️ 按 where 分岔:带 assignmentId 的是「本批全量事件」(新路径), // ⚠️ 按 where 分岔:带 assignmentId 的是「本批全量事件」(新路径),
// 带 planId 的是老批次回落路径 —— 后者在本 spec 里一律为空。 // 带 planId 的是老批次回落路径 —— 后者在本 spec 里一律为空。
...@@ -229,17 +248,33 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () => ...@@ -229,17 +248,33 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () =>
dist: Array<{ outcome: string; n: number }>, dist: Array<{ outcome: string; n: number }>,
) => { ) => {
const { prisma } = makePrisma({ plans: [], ledger }); const { prisma } = makePrisma({ plans: [], ledger });
let call = 0; // ⚠️ 认 SQL 不认次序(见 sqlKind 的注释:按次序分派被新增查询撞散过一次)
(prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest.fn(async () => { (prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest.fn(async (q: unknown) => {
call += 1; switch (sqlKind(q)) {
return call === 1 case 'ledger':
? [{ return [{
assignment_id: BATCH, assignment_id: BATCH,
planned: BigInt(ledger.planned), agents: BigInt(ledger.agents), planned: BigInt(ledger.planned), agents: BigInt(ledger.agents),
released: BigInt(ledger.released), expired: BigInt(ledger.expired), released: BigInt(ledger.released), expired: BigInt(ledger.expired),
revoked: BigInt(ledger.revoked), revoked: BigInt(ledger.revoked),
}] }];
: dist.map((d) => ({ outcome: d.outcome, n: BigInt(d.n) })); case 'outcomeDist':
return dist.map((d) => ({ outcome: d.outcome, n: BigInt(d.n) }));
case 'outcomeRecords':
// 明细:按分布展开成逐条 —— 与聚合同源,这样 records.length 天然对得上四个桶
return dist.flatMap((d, di) =>
Array.from({ length: d.n }, (_, i) => ({
plan_id: `p-${di}-${i}`,
patient_name: `患者${di}-${i}`,
operator_user_id: 'a',
outcome: d.outcome,
notes: i === 0 ? `纪要 ${d.outcome}` : null,
created_at: new Date('2026-08-05T02:00:00Z'),
})),
);
default:
return [];
}
}); });
return prisma; return prisma;
}; };
...@@ -259,8 +294,12 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () => ...@@ -259,8 +294,12 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () =>
expect(o.failed).toBe(5); // 4 + 1 expect(o.failed).toBe(5); // 4 + 1
expect(o.keep).toBe(5); expect(o.keep).toBe(5);
expect(o.noOutcome).toBe(5); // 20 − 15 expect(o.noOutcome).toBe(5); // 20 − 15
expect(o.note).toContain('明确拒绝 4'); // ⚠️ 原来是在 `note` 那句成品句子里找「明确拒绝 4」——note 已删(见下方那条测试),
expect(o.note).toContain('已在外院治疗 1'); // 改成查 `byOutcome`:不成功的**原因明细**本来就该从结构化字段里读
const giveUp = Object.fromEntries(
o.byOutcome.filter((b) => b.group === 'give_up').map((b) => [b.labelZh, b.n]),
);
expect(giveUp).toEqual({ 明确拒绝: 4, 已在外院治疗: 1 });
// ⭐ 四桶穷尽 // ⭐ 四桶穷尽
expect(o.success + o.failed + o.keep + o.noOutcome).toBe(20); expect(o.success + o.failed + o.keep + o.noOutcome).toBe(20);
}); });
...@@ -292,32 +331,66 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () => ...@@ -292,32 +331,66 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () =>
expect(o.noOutcome).toBe(9); expect(o.noOutcome).toBe(9);
}); });
test('⭐ 一条结果都没有时,note 要明说"不是效果不好"', async () => { /**
* ⚠️ 这三条原来断言的是 `outcomes.note` 那句成品句子的**措辞**。
* 2026-08-08 该字段删掉了(它把给模型的指令混进了给人看的数,模型照抄后
* 「不要算成功率、不要画图」直接贴进了主管的对话框),
* 护栏改由 MCP 工具说明承担 —— 所以断言跟着落到**结构**上:
* 措辞会改,而"哪个结果算进哪个桶"是不能变的。
*/
test('⭐ 一条结果都没有 → 全进 noOutcome,⛔ 不许混进 failed', async () => {
const svc = await build( const svc = await build(
withOutcomes({ planned: 8, agents: 1, released: 0, expired: 0, revoked: 0 }, []), withOutcomes({ planned: 8, agents: 1, released: 0, expired: 0, revoked: 0 }, []),
); );
const o = (await svc.detail(SCOPE, BATCH)).outcomes; const o = (await svc.detail(SCOPE, BATCH)).outcomes;
expect(o.noOutcome).toBe(8); expect(o.noOutcome).toBe(8);
expect(o.note).toContain('还没有任何通话结果'); // 🔴 「没做」⛔ 不是「做了没成」—— 混进 failed 会把执行问题读成召回问题
expect(o.note).toContain('不是"效果不好"'); expect(o.failed).toBe(0);
expect(o.success + o.failed + o.keep + o.noOutcome).toBe(8);
}); });
test('⭐ 样本 <50 时 note 必须写明"不要算成功率、不要画图"', async () => { test('⭐ 「约定下次回访」算 success,⛔ 不算 keep/failed', async () => {
const svc = await build( const svc = await build(
withOutcomes({ planned: 20, agents: 1, released: 0, expired: 0, revoked: 0 }, [ withOutcomes({ planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'success_appointed', n: 2 }, { outcome: 'scheduled_next', n: 2 },
]), ]),
); );
expect((await svc.detail(SCOPE, BATCH)).outcomes.note).toContain('样本量不足'); const o = (await svc.detail(SCOPE, BATCH)).outcomes;
// 约到下次也是有效推进 —— 这正是那句被删掉的提醒背后的**事实**
expect(o.success).toBe(2);
expect(o.keep).toBe(0);
expect(o.failed).toBe(0);
expect(o.noOutcome).toBe(3);
}); });
test('⭐ note 要提醒「成功」含约定下次回访,⛔ 别读成都成交了', async () => { test('⭐⭐ 通话记录明细与四个桶**对得上**:records.length === 成功+不成功+没进展', async () => {
const svc = await build( const svc = await build(
withOutcomes({ planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 }, [ withOutcomes({ planned: 20, agents: 2, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'scheduled_next', n: 2 }, { outcome: 'success_appointed', n: 3 },
{ outcome: 'refused', n: 4 },
{ outcome: 'no_answer', n: 5 },
]),
);
const o = (await svc.detail(SCOPE, BATCH)).outcomes;
// 🔴 明细和聚合必须同源同口径(T14):对不上主管就只能猜哪个是真的
expect(o.records).toHaveLength(o.success + o.failed + o.keep);
// ⛔ 「没有结果」的人**不许**在明细里造空行凑数
expect(o.records).toHaveLength(20 - o.noOutcome);
expect(o.recordsTruncated).toBe(false);
// 纪要没写的按 null 给 —— 界面据此显示"没留纪要"(空字符串也归 null)
expect(o.records.some((r) => r.notes === null)).toBe(true);
expect(o.records.some((r) => typeof r.notes === 'string' && r.notes.length > 0)).toBe(true);
// group 与四个桶同源,界面据它上色
expect(new Set(o.records.map((r) => r.group))).toEqual(new Set(['close', 'give_up', 'keep']));
});
test('⛔ `outcomes` 不再返回成品句子 note —— 里面混着给模型的指令,会被原样贴进对话框', async () => {
const svc = await build(
withOutcomes({ planned: 20, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'success_appointed', n: 2 },
]), ]),
); );
expect((await svc.detail(SCOPE, BATCH)).outcomes.note).toContain('约定下次回访'); expect((await svc.detail(SCOPE, BATCH)).outcomes).not.toHaveProperty('note');
}); });
test('⛔ 通话成效与退回原因是两套数,不能互相顶替', async () => { test('⛔ 通话成效与退回原因是两套数,不能互相顶替', async () => {
......
...@@ -55,6 +55,9 @@ function makePrisma(opts: { ...@@ -55,6 +55,9 @@ function makePrisma(opts: {
), ),
}, },
planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) }, planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) },
// 「已处置」按人拆走**执行口径**(有通话结果才算)——空 = 这批一条结果都没回写,
// ⚠️ 那正是生产的常态(回写率约 11%),⛔ 别为了让数字好看在 stub 里编几条
planExecution: { groupBy: jest.fn(async () => []) },
// 批次名要按**宿主时区**格式化(主管说的"今天下午那批"是他墙上的时间) // 批次名要按**宿主时区**格式化(主管说的"今天下午那批"是他墙上的时间)
host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) }, host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
// ⚠️ 账本口径的计数走 $queryRaw。这里返回空 = 模拟「**老批次**」(账本里没有批次号), // ⚠️ 账本口径的计数走 $queryRaw。这里返回空 = 模拟「**老批次**」(账本里没有批次号),
......
...@@ -36,6 +36,37 @@ function EntryResolver() { ...@@ -36,6 +36,37 @@ function EntryResolver() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
/**
* 🔴 **主管落到主管工作台,⛔ 不再滑进某个患者的详情页**(2026-08-07 产品定)。
*
* 在此之前主管和客服走**同一条**落地规则:先我的进行中、再召回池第一个 ——
* 于是他一登录就站在**某一个患者**的详情页上,而他这一屏要做的是
* "我这周分了多少、拿回来多少、下一批分给谁"。这与 08-04 评审那句
* 「场景和思路不应该去混」是同一个问题的两面(见 doctrine 里被改写的 T16)。
*
* ⚠️ 这只改**落地默认**,⛔ 不是把执行页锁掉:
* 主管本质也是客服,他从工作台头部的「客服执行页 ↗」照样进得来,
* 进来之后下面那套规则原样生效(所以它一行都没删)。
* ⭐ 这里**不存在**"权限还没加载完就抢跑"的问题:`permissions` 来自 **JWT 即时解码**,
* 而 `AuthGate` 拿到 token 才渲染 children —— 本组件挂载时它一定已经在了。
* ⚠️ 别把它和 `clinicIds` 搞混:**那个**才是 `/auth/session` 异步回来的
* (工作台的诊所选择器就为此踩过 —— 第一帧锁错诊所,两栏数据对不上却不报错)。
*/
/**
* ⚠️ `?exec=1` = **主管明确要来执行页**(工作台头部那个「客服执行页 ↗」带的)。
*
* 🔴 不给这个出口,那个链接就是**死的**(2026-08-07 实测):点它 → `/plans` →
* 这里又把他弹回 `/supervisor`,肉眼看就是"没反应"。
* 落地默认要改,但**明确的导航意图不能被默认覆盖**。
* ⚠️ 读 `window.location.search` 而不是 `useSearchParams()`:后者会让这张静态页
* 退化成必须包 Suspense 的 CSR bailout,为一个开关不值当。
*/
const wantsExec =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('exec');
if (canDispatch && !wantsExec) {
router.replace('/supervisor');
return;
}
(async () => { (async () => {
try { try {
// ① 我的进行中(优先级最高的一个) // ① 我的进行中(优先级最高的一个)
......
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Permission } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission';
import { SupervisorWorkbench } from '@/components/supervisor/supervisor-workbench';
/**
* /supervisor — 主管工作台。
*
* 🔴 **它是一条独立路由,这推翻了 T16 的后半句**(2026-08-04 产品评审定,doctrine 已改写)。
* 原教条:「矩阵是池子的一个视图模式,不新开路由 —— 主管本质也是客服,
* 割裂成两个页面会把他劈成两个身份」。评审上需求方三次讲同一件事:
* 「场景和思路不应该去混」「领导想的是怎么制定战略,到你这儿人就应该执行」
* 「不搭嘎的事情混在一起」。
*
* ⚠️ T16 里**没被推翻**的那半句仍然作数:主管也要打电话。所以头部常驻
* 「客服执行页 ↗」,两个页面是双向的 —— ⛔ 别做成有去无回。
*
* 权限走 `PLAN_DISPATCH`(派单权),与召回池 tab 同一把闸。
*/
export default function SupervisorPage() {
const router = useRouter();
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
/**
* 🔴 **没有派单权的(客服)弹回执行页**,⛔ 不能只是"不渲染"。
*
* 这里原来包的是 `<Can perm={PLAN_DISPATCH}>`,而 `Can` **没给 fallback 就渲染 null** ——
* 客服落到这条路由看到的是**一整片纯白**:不报错、没提示、也没有任何出口,
* 只能自己去改地址栏(2026-08-08 实测:模拟登录成功后会**留在当前 URL**,
* 于是在工作台上切成客服身份,就直接白屏了;收藏夹/分享链接同理)。
*
* ⚠️ 这是 `/plans` 那条规则的**镜像**,两边合起来才是完整的:
* 有派单权 + 没带 `?exec=1` → /plans 跳 /supervisor
* 没有派单权 → /supervisor 跳 /plans
* ⛔ 不会来回弹:两个条件互斥(`canDispatch` 真/假各占一边),
* 且这边**不看** `?exec=1`(那个开关只表达"主管要去执行页",客服身上没有意义)。
* ⭐ 不存在"权限没加载完就抢跑":`permissions` 来自 **JWT 即时解码**,
* 而 `AuthGate` 拿到 token 才渲染 children —— 本组件挂载时它一定已经在了。
* ⚠️ 别和 `clinicIds` 搞混,**那个**才是 `/auth/session` 异步回来的。
*/
useEffect(() => {
if (!canDispatch) router.replace('/plans');
}, [canDispatch, router]);
if (!canDispatch) return null;
return <SupervisorWorkbench />;
}
...@@ -282,3 +282,64 @@ body { ...@@ -282,3 +282,64 @@ body {
/* 无障碍:不飞,直接不出现(移交本身仍照常发生,只是没有这段演出) */ /* 无障碍:不飞,直接不出现(移交本身仍照常发生,只是没有这段演出) */
.pac-stream-dot { display: none; } .pac-stream-dot { display: none; }
} }
/* ── 「分一批新的 · 里世界」传送门 ────────────────────────────────
幕布 = 一个**小圆**(200×200)从按钮位置 `transform: scale()` 放大到盖满,
内容在幕布快盖满时 `opacity` 淡入。
🔴 **别再改回 `clip-path: circle()`**(2026-08-07 连着两轮翻车):
① 定位:`at <x> <y>` 是按元素的 reference box 算的,不是视口。
`fixed` 元素的包含块只要被祖先的 transform / filter / backdrop-filter /
contain 劫走,圆心就整体偏掉,而且**不报错**——
在开发面板里量得分毫不差,产品那边却"不是从鼠标处扩散的"。
现在用 `absolute` + `left/top`,量的和用的是**同一个盒子**,偏不了。
② 性能:clip-path 不上合成层,动画那半秒**每帧重画整屏**(含 48 个格子)。
现在只动 `transform` / `opacity`,全程合成层、零重绘。
⛔ 幕布别做成全屏大小再 scale:纹理按未缩放尺寸申请(2700² ≈ 28MB)。
小圆放大才是便宜的那条。
⚠️ 200×200 这个尺寸与组件里的 `PORTAL_DISC_R = 100` 是一对,改一处要改两处。
⚠️ 幕布是**纯色**、内容层才带渐变:淡入时两者交叠,
纯色取的是渐变的中段色,所以看不出接缝。⛔ 别给幕布也上渐变 ——
渐变会跟着 scale 一起放大,糊成一团。 */
.pac-portal-disc,
.pac-portal-disc-out {
position: absolute;
width: 200px;
height: 200px;
margin: -100px 0 0 -100px;
border-radius: 9999px;
background: #04225c;
will-change: transform;
}
/* ⚠️ 缓动别用 `ease-out` —— 起手就是满速,圆在头几十毫秒里就铺开半屏,人眼抓不住起点 */
.pac-portal-disc { animation: portalDisc .5s cubic-bezier(.4, 0, .2, 1) forwards; }
.pac-portal-disc-out { animation: portalDisc .34s cubic-bezier(.4, 0, 1, 1) reverse forwards; }
@keyframes portalDisc {
from { transform: scale(0); }
to { transform: scale(var(--s, 1)); }
}
/* 内容:等幕布铺到九成再进场 —— ⛔ 延迟别小于 .3s,否则内容会在屏幕外的黑边上先亮起来 */
.pac-portal-body { animation: portalBody .26s ease-out .34s both; }
.pac-portal-body-out { animation: portalBody .14s ease-in reverse both; }
@keyframes portalBody {
from { opacity: 0; transform: scale(.985); }
to { opacity: 1; transform: none; }
}
/* 敲下去:先陷进去再弹没 */
@keyframes whack {
0% { transform: translateY(0) scale(1); }
35% { transform: translateY(6px) scale(0.9); }
100% { opacity: 0; transform: translateY(16px) scale(0.7); }
}
/* 冲击环 */
@keyframes ringOut {
from { opacity: 0.9; transform: scale(0.4); }
to { opacity: 0; transform: scale(2.4); }
}
@media (prefers-reduced-motion: reduce) {
/* 无障碍:传送门直接到位、敲击不做形变 —— ⚠️ 移交本身照常发生,只是没有这段演出 */
.pac-portal-disc,
.pac-portal-disc-out { animation: none !important; transform: scale(var(--s, 1)); }
.pac-portal-body,
.pac-portal-body-out { animation: none !important; }
}
'use client'; 'use client';
import type { import type {
AgentWorkloadResponse,
CreateAssignmentRequest, CreateAssignmentRequest,
CreateAssignmentResponse, CreateAssignmentResponse,
ListAgentsResponse, ListAgentsResponse,
...@@ -29,8 +30,29 @@ export const assignmentsApi = { ...@@ -29,8 +30,29 @@ export const assignmentsApi = {
create: (body: CreateAssignmentRequest) => create: (body: CreateAssignmentRequest) =>
api.post<CreateAssignmentResponse>('/pac/v1/plans/assignments', body), api.post<CreateAssignmentResponse>('/pac/v1/plans/assignments', body),
list: (mine?: boolean) => /**
api.get<ListAssignmentsResponse>(`/pac/v1/plans/assignments${mine ? '?mine=1' : ''}`), * 批次列表。
* ⚠️ `before` 是**游标**(上一页最后一条的 createdAt),⛔ 不是 offset ——
* 批次一直在新增,offset 翻页会重复或漏行。
*/
list: (opts?: { mine?: boolean; limit?: number; before?: string }) => {
const q = new URLSearchParams();
if (opts?.mine) q.set('mine', '1');
if (opts?.limit) q.set('limit', String(opts.limit));
if (opts?.before) q.set('before', opts.before);
const s = q.toString();
return api.get<ListAssignmentsResponse>(`/pac/v1/plans/assignments${s ? `?${s}` : ''}`);
},
/**
* 团队现在什么状态 —— 主管工作台右栏。
* ⚠️ 只有「当前在手」是此刻的,超期/完成/退回/没动都在 `days` 这个窗口里;
* 响应里的 `note` 已经写好这句,⛔ 界面照抄别自己另写一版。
*/
workload: (clinicId: string, days: number) =>
api.get<AgentWorkloadResponse>(
`/pac/v1/plans/assignments/workload?clinicId=${encodeURIComponent(clinicId)}&days=${days}`,
),
detail: (id: string) => detail: (id: string) =>
api.get<AssignmentDetailResponse>(`/pac/v1/plans/assignments/${encodeURIComponent(id)}`), api.get<AssignmentDetailResponse>(`/pac/v1/plans/assignments/${encodeURIComponent(id)}`),
......
...@@ -53,10 +53,28 @@ const HUE: Record<TempKey, { dot: string; text: string }> = { ...@@ -53,10 +53,28 @@ const HUE: Record<TempKey, { dot: string; text: string }> = {
cold_over: { dot: 'bg-slate-400', text: 'text-slate-500' }, cold_over: { dot: 'bg-slate-400', text: 'text-slate-500' },
}; };
/// 整片矩阵那条渐变 —— 一处定义,列头刻度与格子面共用;写两遍必然漂 /**
/// ⚠️ 冷端占了 4/6 的宽度,所以 sky 段要给足停靠点,否则右侧一大片糊成一个颜色、分不出四档。 * 整片矩阵那条渐变 —— 一处定义,列头刻度与格子面共用;写两遍必然漂。
*
* 🔴 2026-08-07 **收尾从 slate 改回 sky**(产品对着稿子指出来的)。
* 在此之前:注释和上面的 HUE 都说冷端是 **sky**,而这条渐变实际收在 `slate-400/25` ——
* **面和点对不上**:列头三个圆点是蓝的,它们底下那片是灰的。
* 整条轴于是读成"由烫到**灰**",而设计意图是"由烫到**冷**"——冷是蓝,灰是没有信息。
* ⚠️ 冷端占了 4/6 的宽度,`via-45%` 把 emerald 的停靠点往左压,
* 让 emerald→sky 的过渡铺满右半边,否则右侧一大片糊成一个颜色、分不出四档。
* ⚠️ 最后一档(3 年以上)的**灰**只留在列头圆点上(HUE.cold_over):
* 那是"这一列是谁"的标识,而这张面是一条连续的轴,不需要把最后一格单独编码。
* 🔴 2026-08-07 **整体调浅**(同一次走查):原来走 `-600` 档 + 高透明度(`amber-600/55`),
* 出来是"实色蒙了一层白" —— 面很浓,格子里的数字要跟它抢对比度。
* 改走 **`-300` 档 + 三成左右**:面退成奶油/薄荷/淡天蓝的底,数字才是主角。
* ⚠️ 这三档的取值是按产品认可的稿子反算的(#F3C969 / #7FD1B9 / #8FC5E8 各 28% 叠白),
* ⛔ 别再往回调深 —— 面是背景不是内容,它只负责"由烫到冷"这一个信息。
* ⚠️ 列头圆点仍走 `-600/-500/-400`(见上面 HUE):**小圆点需要饱和度才看得见**,
* 与这张大面积的底是两种用途,⛔ 不要为了"一致"把两边调成同一档。
* ⚠️ 仍用 `/nn` 透明度而不是十六进制 —— 见文件头注释那条「别在组件里写死色值」。
*/
const PLANE = const PLANE =
'bg-linear-to-r from-amber-600/55 via-emerald-600/45 via-45% to-slate-400/25'; 'bg-linear-to-r from-amber-300/35 via-emerald-300/30 via-45% to-sky-300/32';
/// 行标签列宽 + 行高:列头、标签列、格子面三者靠它们对齐,别在某一处手改 /// 行标签列宽 + 行高:列头、标签列、格子面三者靠它们对齐,别在某一处手改
const LABEL_W = 'w-[46px]'; const LABEL_W = 'w-[46px]';
...@@ -66,6 +84,13 @@ export function PoolMatrix({ ...@@ -66,6 +84,13 @@ export function PoolMatrix({
data, data,
selected, selected,
onPick, onPick,
/**
* 外层容器样式覆盖。
* ⚠️ 默认 `w-[560px]` 是给**左栏那个 popover** 的(浮层要贴着按钮,不能撑满屏);
* 主管工作台的 modal 要它填满,传 `w-full` 覆盖。
* ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。
*/
className,
}: { }: {
/** /**
* 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。 * 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。
...@@ -77,6 +102,7 @@ export function PoolMatrix({ ...@@ -77,6 +102,7 @@ export function PoolMatrix({
data: PoolMatrixData; data: PoolMatrixData;
/** 当前选中的格子(回显用) */ /** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null; selected?: { treatment: string; temperature: TempKey } | null;
className?: string;
/** /**
* 点格子 → 初选完成,把这一格的人群交出去。 * 点格子 → 初选完成,把这一格的人群交出去。
* ⚠️ 带 `rect`(该格子的视口矩形):数据流动画的**起点**。 * ⚠️ 带 `rect`(该格子的视口矩形):数据流动画的**起点**。
...@@ -92,7 +118,7 @@ export function PoolMatrix({ ...@@ -92,7 +118,7 @@ export function PoolMatrix({
}) { }) {
const showUnknown = data.unknownTotal > 0; const showUnknown = data.unknownTotal > 0;
return ( return (
<Card className="w-[560px] border-0 shadow-none"> <Card className={cn('w-[560px] border-0 shadow-none', className)}>
{/* {/*
⛔ **不放列合计**。列合计 = 该列 8 行相加,而**一个人可以同时出现在多行** ⛔ **不放列合计**。列合计 = 该列 8 行相加,而**一个人可以同时出现在多行**
(有几个潜在治疗就占几行,实测人均 1.37 个)。于是六列一加会**大于**池子总人数 (有几个潜在治疗就占几行,实测人均 1.37 个)。于是六列一加会**大于**池子总人数
......
'use client';
import Link from 'next/link';
import { ArrowUpRight, Building2, Check, ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { cn } from '@/lib/utils';
/**
* 主管工作台的头部 —— 沿用 PAC 现有头部的骨架,但**换成主管性质**(2026-08-07 设计稿)。
*
* ── 与客服执行页那个 header 的差别(都是刻意的)────────────────────
* 去掉:场景 chip / 刷新 / 预约 / 「还剩 N 天退回」
* —— 那四件都是**单条任务**的东西,主管这一屏根本没有"当前这条"。
* 加上:**诊所切换** + 「客服执行页 ↗」
* —— 前者是主管才有的视角(他管的是一个诊所而不是一条单);
* 后者是两个页面分家之后的**回去的路**,⛔ 不能只出不进。
*
* ⚠️ 嵌入宿主(iframe)时整栏隐藏 —— 与现有 header 同一条规矩:宿主自有品牌与导航。
*/
export function SupervisorHeader({
clinicId,
onClinicChange,
}: {
clinicId: string | null;
onClinicChange: (id: string) => void;
}) {
const user = useAuthStore((s) => s.user);
const clinics = visibleClinics(user);
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
// 点外面收起 —— 下拉挂在 header 上,不收起会挡住下面的表
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!boxRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
if (isEmbedded()) return null;
const current = clinics.find((c) => c.id === clinicId);
return (
<header className="flex h-12 flex-none items-center gap-2.5 border-b border-slate-100 bg-white px-3 sm:px-4">
<span className="inline-flex h-7 w-7 flex-none items-center justify-center rounded-md bg-brand-600 text-[12px] font-bold text-white">
PAC
</span>
{/* 面包屑与执行页同构(疗效保障 / X)——⛔ 别只写「主管工作台」:
两个页面同属一个产品,面包屑一致才看得出是同一套东西的两面 */}
<span className="hidden lg:inline text-[13px] font-semibold text-slate-900">疗效保障</span>
<span className="hidden lg:inline text-slate-300">/</span>
<h1 className="flex-none text-[14px] font-semibold text-slate-900">主管工作台</h1>
{/* 诊所切换 —— 只有一个诊所时**不给下拉**(点开只有一项是噪音),直接显示名字 */}
{clinics.length > 0 && (
<div ref={boxRef} className="relative flex-none">
<button
type="button"
disabled={clinics.length < 2}
onClick={() => setOpen((v) => !v)}
className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border border-slate-100 px-2 text-[12px] text-slate-600',
clinics.length > 1 && 'hover:bg-slate-50',
)}
>
<Building2 className="h-3.5 w-3.5 flex-none text-slate-400" />
<span className="max-w-[10rem] truncate">{current?.name ?? '选择诊所'}</span>
{clinics.length > 1 && <ChevronDown className="h-3 w-3 flex-none text-slate-300" />}
</button>
{open && (
<div className="absolute left-0 top-8 z-30 max-h-72 w-56 overflow-auto rounded-lg border border-slate-100 bg-white py-1 shadow-lg">
{clinics.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
onClinicChange(c.id);
setOpen(false);
}}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[12.5px] text-slate-700 hover:bg-brand-50"
>
<Check
className={cn(
'h-3.5 w-3.5 flex-none',
c.id === clinicId ? 'text-brand-600' : 'text-transparent',
)}
/>
<span className="truncate">{c.name}</span>
</button>
))}
</div>
)}
</div>
)}
<div className="ml-auto flex items-center gap-2.5">
{/* 🔴 回执行页的路 —— 分家之后**必须双向**。
主管本人也要打电话(T16 那半句仍然成立),只是不在这一屏做。
⚠️ `?exec=1` 不能省:`/plans` 的落地规则会把主管弹回工作台,
不带这个开关,这个链接点了就是**没反应**(实测踩过)。 */}
<Link
href="/plans?exec=1"
className="hidden sm:inline-flex items-center gap-1 text-[12.5px] text-brand-700 hover:underline"
>
客服执行页
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
<span className="hidden sm:block h-4 w-px bg-slate-100" />
<IdentityCluster />
</div>
</header>
);
}
'use client';
import { useEffect, useRef, useState } from 'react';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { AssistantWidget } from '@/components/assistant/assistant-widget';
import { SupervisorHeader } from './supervisor-header';
import { BatchTracking } from './batch-tracking';
import { TeamStatus } from './team-status';
import { NewBatchOverlay, type NewBatchMode } from './new-batch';
import { cn } from '@/lib/utils';
/**
* 主管工作台 —— 左「我分的批次」/ 右「团队现在什么状态」。
*
* ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致):
* 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 ——
* 这页会嵌在宿主 iframe 里,横条比换行难受得多)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下退回率的两个分母(见下方注释)。
*
* ⚠️ 诊所是这一屏**所有查询的前提**(批次、团队都按诊所)。没有诊所就什么都不查,
* ⛔ 别用「全部诊所」兜底:那会把别家的负载混进来,而主管管的是自己这一家。
*/
export function SupervisorWorkbench() {
const user = useAuthStore((s) => s.user);
const clinics = visibleClinics(user);
const [clinicId, setClinicId] = useState<string | null>(null);
/**
* 默认落到第一个可见诊所。
*
* 🔴 **必须校验"当前选的还在不在列表里"**,⛔ 不能只判 `clinicId == null`
* (2026-08-07 实测踩过):`visibleClinics` 在 session 加载**之前**会回落到
* 「字典里的全部诊所」(`user.clinicIds` 还是空的),于是第一帧就把一个
* **别家诊所**锁进了 state;session 回来后列表缩成他自己那一家,
* 而 clinicId 已经指向别处 —— 界面表现是:头部显示「选择诊所」,
* 左边批次是自己的、右边团队是别家的人,**两栏对不上却都不报错**。
* ⚠️ 只在"不在列表里"时才纠正 —— 用户手动切过的选择不能被后续 render 冲掉。
*/
useEffect(() => {
if (clinics.length === 0) return;
if (clinicId != null && clinics.some((c) => c.id === clinicId)) return;
setClinicId(clinics[0]!.id);
}, [clinics, clinicId]);
/**
* 「分一批新的」两版并存(设计稿 `newBatchMode`,产品要求都实现)。
* ⚠️ 记在 localStorage:这是**个人偏好**不是业务配置 —— 主管挑顺手的那个用,
* ⛔ 别做成后台开关(那意味着全院统一,而这两版本来就是给人选的)。
*/
const [mode, setMode] = useState<NewBatchMode>('window');
useEffect(() => {
const v = localStorage.getItem('pac.newBatchMode');
if (v === 'arcade' || v === 'window') setMode(v);
}, []);
const pickMode = (m: NewBatchMode) => {
setMode(m);
localStorage.setItem('pac.newBatchMode', m);
};
const [overlay, setOverlay] = useState(false);
/// 传送门的圆心 = 按钮中心的视口坐标;⛔ 必须在点击那一刻取,浮层开了就量不到了
const originRef = useRef<{ x: number; y: number } | null>(null);
return (
<div className="flex h-screen flex-col overflow-hidden bg-slate-50">
<SupervisorHeader clinicId={clinicId} onClinicChange={setClinicId} />
{/* ⚠️ 工具行**靠右** —— 左边那两块面板才是主管进来要看的东西;
「分一批新的」是他看完之后的动作,摆在左上角会跟「我分的批次」标题抢第一眼。 */}
<div className="flex flex-none items-center justify-end gap-2 px-3 pt-3 xl:px-4">
{/* 两版切换 —— 挨着入口放,主管点之前就知道会进哪一版 */}
<span className="inline-flex rounded-lg bg-slate-100 p-0.5">
{(
[
{ m: 'window', label: '样式1' },
{ m: 'arcade', label: '样式2' },
] as const
).map((o) => (
<button
key={o.m}
type="button"
onClick={() => pickMode(o.m)}
className={cn(
'rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
mode === o.m
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
{o.label}
</button>
))}
</span>
<button
type="button"
disabled={!clinicId}
onClick={(e) => {
/**
* 圆心 = **按钮中心**(2026-08-07 产品定:「改成从按钮扩散也行」)。
*
* ⚠️ 之前试过用鼠标落点 `clientX/Y`,产品那边连着两轮都说"不在点击处"。
* 真正的病根不在这儿(是幕布的定位方式,见 new-batch.tsx 的长注释),
* 但按钮中心**本来就够用**:按钮才 100 出头,误差顶天 50px,
* 而它是确定的、跟键盘触发也是同一个值 —— 少一条会静默走偏的分支。
* ⛔ 别再改回 `clientX/Y`:键盘触发时那两个值是 0,
* 一旦哪天漏了兜底,传送门就从屏幕左上角炸开。
*/
const r = e.currentTarget.getBoundingClientRect();
originRef.current = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
setOverlay(true);
}}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-[12.5px] font-medium text-white hover:bg-brand-700 disabled:opacity-40"
>
+ 分一批新的
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3 xl:flex-row xl:overflow-hidden xl:p-4">
<section className="flex min-h-[26rem] min-w-0 flex-1 xl:min-h-0">
<BatchTracking clinicId={clinicId} />
</section>
{/* ⚠️ 470px(设计稿宽度)装不下「退回率」那一列:
百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」——
两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。
⇒ 放宽到 34rem;⛔ 别为了塞进 470 去砍分母。 */}
<section className="flex min-h-[24rem] flex-none xl:min-h-0 xl:w-[34rem]">
<TeamStatus clinicId={clinicId} />
</section>
</div>
{overlay && (
<NewBatchOverlay
mode={mode}
clinicId={clinicId}
origin={originRef.current}
onClose={() => setOverlay(false)}
/>
)}
{/* 🔴 助手必须挂在这一页上 —— 挑完格子走 `handoff()`,落点就是它。
⚠️ 它原本只挂在 `plans/layout`;工作台是**另一条路由**(T16 已改写),
⛔ 不挂的话点完格子什么都不会发生,而且不报任何错。 */}
<AssistantWidget />
</div>
);
}
'use client';
import { useEffect, useState } from 'react';
import type { AgentWorkloadResponse } from '@pac/types';
import { assignmentsApi } from '@/components/plans/assignments-api';
import { cn } from '@/lib/utils';
/// 窗口档位。⚠️ 只给两档:主管要的是"这周怎么样 / 这个月怎么样",更细他也不看
const WINDOWS = [
{ days: 7, label: '近 7 天' },
{ days: 30, label: '近 30 天' },
] as const;
/**
* 团队现在什么状态 —— 主管工作台右栏。
*
* ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手** —— 此刻手上还压着多少,与窗口无关;
* · **超期 / 完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以表头第一列写死「**当前**在手」。只写「在手」时实测会被读成"这 7 天分了 62 条"。
*
* 🔴 「超期」是**窗口口径**(2026-08-07 实测推翻了"当前超期"):
* 到期回收器每 10 分钟扫一遍,过期的单当场被收走 —— "当前还压在手上且已过期"
* 结构上几乎永远是 0(实测账本 378 条到期回收 vs 当前在手已过期 0 条)。
* 摆那个数上去,主管会以为团队从不超期。
*
* ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)——
* 只给百分比会把"没动"藏起来,而那个数本身就是信号。
*/
export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [days, setDays] = useState<number>(7);
const [data, setData] = useState<AgentWorkloadResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!clinicId) return;
let alive = true;
setLoading(true);
setError(null);
assignmentsApi
.workload(clinicId, days)
.then((r) => alive && setData(r))
.catch((e) => alive && setError(e instanceof Error ? e.message : '加载失败'))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [clinicId, days]);
const rows = data?.agents ?? [];
const inHandTotal = rows.reduce((s, a) => s + a.inHand, 0);
/**
* 「超期最多」的判据。
* 🔴 **并列第一时一个都不标**(2026-08-07 实测:17 位客服全是 23 条,结果每一行都挂着
* 「超期最多」—— 标一片等于没标,而且看着像系统坏了)。
* 只有**唯一的最高**且确实高过第二名时才标 —— 那才是"这个人要单独看一眼"的信号。
*/
const sorted = [...rows].map((a) => a.overdue).sort((x, y) => y - x);
const top = sorted[0] ?? 0;
const flagOverdue = top > 0 && top > (sorted[1] ?? 0) ? top : null;
return (
<div className="flex min-h-0 w-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white">
<div className="flex flex-none flex-wrap items-center gap-x-2.5 gap-y-1 border-b border-slate-50 px-3.5 py-2.5">
<span className="text-[13.5px] font-semibold text-slate-900">团队现在什么状态</span>
<span className="nums text-[11px] text-slate-400">
{rows.length} 位在岗 · 在手 {inHandTotal}
</span>
<span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5">
{WINDOWS.map((w) => (
<button
key={w.days}
type="button"
onClick={() => setDays(w.days)}
className={cn(
'nums rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
days === w.days
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
{w.label}
</button>
))}
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{error ? (
<p className="px-3.5 py-6 text-center text-[12px] text-rose-600">{error}</p>
) : loading && rows.length === 0 ? (
<div className="space-y-2 p-3.5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-slate-100" />
))}
</div>
) : rows.length === 0 ? (
<p className="px-3.5 py-8 text-center text-[12px] text-slate-400">
{clinicId ? '这个诊所近 12 个月没有回访记录,名册是空的' : '请先选择诊所'}
</p>
) : (
<table className="w-full border-collapse text-[12.5px] text-slate-700">
<thead>
<tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]">
<th className="px-3.5 py-1.5 text-left text-[11px] font-medium text-slate-500">
客服
</th>
{/* 🔴 「当前」两个字不能省 —— 它是这一列与右边四列的唯一区分 */}
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
当前在手
</th>
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
超期
</th>
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
完成
</th>
<th className="whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
退回率
</th>
</tr>
</thead>
<tbody>
{rows.map((a) => {
const worst = flagOverdue != null && a.overdue === flagOverdue;
return (
<tr key={a.userId} className="border-t border-slate-50">
<td className="whitespace-nowrap px-3.5 py-2">
<span className="font-medium text-slate-900">{a.name ?? a.userId}</span>
{worst && (
<span className="ml-1.5 text-[10.5px] text-amber-700">超期最多</span>
)}
</td>
<td className="nums px-2.5 py-2 text-right">{a.inHand}</td>
{/*
🔴 **超期 > 0 就上琥珀**(2026-08-07 产品问「需不需要颜色」)。
在此之前「完成」是绿的、「超期」是黑的 —— 好事有色、坏事没色,
一行扫过去眼睛只被绿色勾住,而主管要找的恰恰是超期那一列。
⚠️ 语义色与「没动」同一支琥珀(两者都是"到期没人动"的同一件事,
只是一个按批次看、一个按人看)。⛔ 别另挑一个颜色。
⚠️ 0 用 slate-300 压下去 —— 那是好消息,不该占视觉带宽。
⚠️ 「超期最多」那位加粗再强调一档;并列第一时谁都不加粗(见上)。
*/}
<td
className={cn(
'nums px-2.5 py-2 text-right',
a.overdue === 0
? 'text-slate-300'
: worst
? 'font-semibold text-amber-700'
: 'text-amber-700',
)}
>
{a.overdue}
</td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{a.done}</td>
<td className="px-3.5 py-2 text-right">
{/* ⚠️ 百分比与两个分母**分两行**:一行塞不下,而分母不能省 */}
<div className="nums text-slate-900">
{a.handled ? `${((a.released / a.handled) * 100).toFixed(1)}%` : '—'}
</div>
<div className="nums text-[10.5px] leading-snug text-slate-400">
{a.rateNote}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{/*
⛔ 这里原来铺了一段服务端口径说明(「只有『当前在手』是此刻的状态;超期/完成/退回/
没动都在近 N 天这一个窗口里…」)—— 2026-08-07 产品要求去掉:一屏表格下面挂
三行小字,主管扫的时候只当噪音。
⚠️ 那个区分**没有丢**:表头第一列写死「**当前**在手」,其余四列在窗口切换器
(近 7 天 / 近 30 天)正下方 —— 位置本身就说明了它们跟着窗口走。
⚠️ 服务端的 `note` 仍然照常返回(助手要照抄它),⛔ 别顺手把后端那段也删了。
*/}
</div>
</div>
);
}
...@@ -508,8 +508,28 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -508,8 +508,28 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
后端 `assign` 保留(见 T12,认领与分配同构),但**前端不给入口** 后端 `assign` 保留(见 T12,认领与分配同构),但**前端不给入口**
将来要放开客服主动性,是加回一个入口的事,不用改模型。 将来要放开客服主动性,是加回一个入口的事,不用改模型。
**分配入口就近召回池** —— 矩阵是池子的一个视图模式(列表 ⇄ 矩阵切换),不新开路由、不换心智。 > ### ⛔ 下面这段已被推翻(2026-08-04 产品评审),保留原文当病史
主管本质也是客服,也要执行,割裂成两个页面会把他劈成两个身份。 >
> **分配入口就近召回池** —— 矩阵是池子的一个视图模式(列表 ⇄ 矩阵切换),不新开路由、不换心智。
> 主管本质也是客服,也要执行,割裂成两个页面会把他劈成两个身份。
**改判:主管有一条独立路由 `/supervisor`(2026-08-04 评审定,08-07 落地)。**
推翻它的不是设计偏好,是需求方在同一场评审里讲了三遍的同一件事:
> 「就是**场景和思路不应该去混**……领导想的是我怎么去制定战略,
> 到你这儿人就是应该执行、应该打电话……**不搭嘎的事情混在一起**。」
> 「我觉得他[主管]应该是有一个他自己的 view,不是像现在这种跟话术混在一起、
> 跟一个患者明细混在一起。」
原教条错在**把"同一个人"当成了"同一个场景"**。主管确实也打电话——但那是他换一顶帽子之后的事,
不是同一屏里的事。旧写法的实际后果:他要出一版分配方案,得先进**某个患者**的详情页,
在话术和病历中间找到那个浮层。
⚠️ **没被推翻的那半句仍然作数**:主管本质也是客服。所以两个页面必须**双向**——
`/supervisor` 头部常驻「客服执行页 ↗」,⛔ 别做成有去无回。
⚠️ 权限没变:仍是 `PLAN_DISPATCH` 一把闸,与召回池 tab 同源。⛔ 别为新路由另立权限。
### T17 · 表设计保守立柱:会用来筛的才立柱,其余进 JSON ### T17 · 表设计保守立柱:会用来筛的才立柱,其余进 JSON
......
...@@ -254,12 +254,41 @@ export const AssignmentBriefSchema = z.object({ ...@@ -254,12 +254,41 @@ export const AssignmentBriefSchema = z.object({
* 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。 * 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。
* ⚠️ 是**处理**不是**成功**:只说"这单动过了",不说"谈成了"。 * ⚠️ 是**处理**不是**成功**:只说"这单动过了",不说"谈成了"。
*/ */
done: z.number().int().describe('已处理(召回已出池 / 被抑制 / 已结案)'), /**
* 「已处理」——**池子口径**:召回已出池 / 被抑制 / 已结案。
* ⚠️ 它答的是「**这批还剩多少活**」,含引擎自己判定无活信号的(患者自己来了,客服没动)。
* ⛔ **别拿它按人拆** —— 那会把"没做"显示成"做了"。按人看用 `handled`。
*/
done: z.number().int(),
/**
* 「已处置」——**执行口径**:本批里真的落了通话结果的条数。
* ⭐ 主管工作台的「已处置」列用这个:它是"**有人真的做了事**"的唯一证据,
* 而 `agentStats[].done` 也是同一口径 —— 各客服加起来能对上这个数。
* ⚠️ 回写率低时这个数会明显小于 `done`(本地实测 5 vs 2、生产回写率约 11%)——
* ⛔ 那是**真实情况**不是 bug,别为了"好看"换回池子口径。
*/
handled: z.number().int(),
/**
* 「约上」—— 本批里最近一次通话结果落在 `close` 组的人数(转化新预约 + **约定下次回访**)。
*
* ⚠️ 与 detail 的 `outcomes.success` **同一定义**,⛔ 别只数"转化新预约":
* 两处都写着"约上",小一号主管就只能猜哪个对。
* ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
* 样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。
*/
booked: z.number().int(),
}); });
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>; export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;
export const ListAssignmentsResponseSchema = z.object({ export const ListAssignmentsResponseSchema = z.object({
items: z.array(AssignmentBriefSchema), items: z.array(AssignmentBriefSchema),
/// 一共多少批(不受分页影响)—— 界面上「已显示 12 / 137 批」的分母
total: z.number().int(),
/**
* 下一页的游标(上一页最后一条的 createdAt);null = 没有下一页了。
* ⚠️ 游标是**时间**不是 offset:批次一直在新增,offset 分页翻第二页会重复或漏行。
*/
nextBefore: z.string().nullable(),
}); });
export type ListAssignmentsResponse = z.infer<typeof ListAssignmentsResponseSchema>; export type ListAssignmentsResponse = z.infer<typeof ListAssignmentsResponseSchema>;
...@@ -280,6 +309,12 @@ export const AssignmentAgentStatSchema = z.object({ ...@@ -280,6 +309,12 @@ export const AssignmentAgentStatSchema = z.object({
* 以为她压着单没动,**实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。 * 以为她压着单没动,**实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
*/ */
overdue: z.number().int().describe('仍在手、已过时效、且没约下次回访'), overdue: z.number().int().describe('仍在手、已过时效、且没约下次回访'),
/**
* 这个客服在本批里已处置多少条。
* ⚠️ 判据与批次级的 `progress.done` **同一个函数**(classifyPlanProgress)——
* ⛔ 各客服的 done 加起来必须等于表头那个总数,主管一定会去加。
*/
done: z.number().int(),
}); });
export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>; export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>;
...@@ -327,8 +362,58 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({ ...@@ -327,8 +362,58 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
n: z.number().int(), n: z.number().int(),
}), }),
), ),
/// 成品句子,助手原话转述 /**
note: z.string(), * 🔴 **逐条明细 + 客服手写的电话纪要**(2026-08-08 新增)。
*
* 在此之前 `plan_executions.notes` **全线读不到**:接口 `GET /plans/:id/executions` 一直在,
* 但前端从没调过、MCP 也没开工具 —— 写进去就再没出来过。
* 主管看到「不成功 1」只能知道有这么个数,问不出"哪个患者、为什么"。
*
* ⚠️ 与上面四个桶**同一口径**:每条单只取**最近一次**执行。
* `records.length === success + failed + keep`(「没有结果」的人没有行)——
* ⛔ 别在这里为他们造空行,那会让明细和聚合对不上数(T14)。
* ⚠️ `notes` 为 null = 客服没留纪要(空字符串也归 null)。
* ⭐ 这本身是信息:结果填了、纪要空着,说明只点了个选项。
*/
records: z.array(
z.object({
planId: z.string(),
patientName: z.string().nullable(),
operatorUserId: z.string(),
operatorName: z.string().nullable(),
outcome: z.string(),
labelZh: z.string(),
/// close(成功)/ give_up(不成功)/ keep(没进展)—— 与四个桶同源
group: z.string(),
/// 触达渠道:phone / wecom / sms / other
channel: z.string(),
/**
* 放弃原因(多选)—— ⚠️ `outcome === 'abandoned'`(「放弃」)时才有,
* 它是**唯一**带子选项的结果项(2026-08-05:子选项描述的是客服的处置决定,
* 不是患者态度,所以只挂「放弃」不挂「明确拒绝」)。
* ⭐ **中文一起给**,⛔ 界面和助手别各自维护翻译表(口径只有一份)。
*/
abandonReasons: z.array(z.object({ reason: z.string(), labelZh: z.string() })),
/// 勾了「治疗机会判断不对」时必填的治疗;⚠️ 这是**客服说算法判错了**,调算法要看它
inaccurateTreatments: z.array(z.object({ code: z.string(), labelZh: z.string() })),
/// 「约定下次回访」约的是哪天;⚠️ 只有 outcome='scheduled_next' 才有
scheduledNextAt: z.string().nullable(),
/// 客服手填的电话纪要;null = 没写
notes: z.string().nullable(),
at: z.string(),
}),
),
/// ⚠️ true = 明细被截断了(上限见服务端 `OUTCOME_RECORD_LIMIT`)。⛔ 界面必须说出来
recordsTruncated: z.boolean(),
/**
* 🔴 这里**曾经有一个 `note: z.string()`(成品句子,助手原话转述),
* 2026-08-08 产品要求删掉**。
*
* 那句话把「给人看的数」和「给模型看的指令」焊在一起
* (「⛔ 不等于都成交了」「**不要算成功率、不要画图**」),
* 而工具说明还写着"照抄最稳" —— 结果模型把内部指令原样贴进了对话框。
* ⛔ 别再加回来:护栏写在**工具说明**里,数留在上面这些结构化字段里。
*/
}), }),
/** /**
* ⚠️ **退回率永远给两个数**:分母是"已处置"(在手 + 已退回之外的都还没动过), * ⚠️ **退回率永远给两个数**:分母是"已处置"(在手 + 已退回之外的都还没动过),
...@@ -747,6 +832,56 @@ export type SheetSelect = z.infer<typeof SheetSelectSchema>; ...@@ -747,6 +832,56 @@ export type SheetSelect = z.infer<typeof SheetSelectSchema>;
export type SheetAssignTo = z.infer<typeof SheetAssignToSchema>; export type SheetAssignTo = z.infer<typeof SheetAssignToSchema>;
export type SheetEditOp = z.infer<typeof SheetEditOpSchema>; export type SheetEditOp = z.infer<typeof SheetEditOpSchema>;
/**
* 🔴 **团队现在什么状态** —— 主管工作台右栏 / `getAgentWorkload`。
*
* ⚠️⚠️ **一张表里混着两种时间性**,界面必须写明,否则主管会把「在手 62」读成"这 7 天分了 62 条":
* · `inHand` —— **此刻**的状态(唯一一个),表头要写「**当前**在手」;
* · `overdue` / `done` / `released` / `handled` / `idle` —— **窗口内**发生的事,换窗口一起变。
* ⚠️ 名册与分配那边**同一个来源**(AgentRosterService)—— 分配问「还能吃多少」、
* 跟踪问「手上压了多少」,是同一份数据的两种读法(T10)。⛔ 别另查一份。
*/
export const AgentWorkloadRowSchema = z.object({
userId: z.string(),
name: z.string().nullable(),
/// **当前**手上还压着多少 —— 这一列是唯一的"此刻"口径(跨诊所,与 AgentInfo.inHand 同源)
inHand: z.number().int(),
/**
* **窗口内**超期 = 这段时间里到期没人动、被收回池子的条数。
*
* 🔴 ⛔ **不是"当前还压在手上且已过期"**(2026-08-07 实测推翻):
* 到期回收器每 10 分钟扫一遍,过期的单当场被收走 —— 那个口径**结构上几乎永远是 0**
* (实测:账本 378 条到期回收,而"当前在手已过期" 0 条)。摆上去是个常年为 0 的死数,
* 主管会以为团队从不超期。
* ⚠️ 归属回捞自"到期前最后一次 assign" —— auto_release 事件本身不带人(释放后无人归属)。
* ⚠️ **约了下次回访的不算**:回收器刻意跳过它们,那是客服动过了的证据。
* 不排掉的话,打了电话、约好下次的人反而被显示成"压着单没动"。
*/
overdue: z.number().int(),
/// 窗口内写过通话结果的条数(按单去重)
done: z.number().int(),
/// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
released: z.number().int(),
/// 窗口内已处置 = done + released(两者都是"这单他动过了")
handled: z.number().int(),
/// 窗口内分到但一条都没动的
idle: z.number().int(),
/**
* ⭐ 成品句子:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
* 🔴 **两个分母都要带** —— 只给一个百分比会把"没动"藏起来,而那个数本身就是信号。
* ⛔ 前端别自己拼:界面、助手、导出三处口径必须一致。
*/
rateNote: z.string(),
});
export type AgentWorkloadRow = z.infer<typeof AgentWorkloadRowSchema>;
export const AgentWorkloadResponseSchema = z.object({
clinicId: z.string(),
windowDays: z.number().int(),
agents: z.array(AgentWorkloadRowSchema),
});
export type AgentWorkloadResponse = z.infer<typeof AgentWorkloadResponseSchema>;
// ============================================================= // =============================================================
// 撤销整批 —— POST /pac/v1/plans/assignments/:id/revoke // 撤销整批 —— POST /pac/v1/plans/assignments/:id/revoke
// ============================================================= // =============================================================
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment