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 {
'\n⚠️ `outcomes.noOutcome`(一次结果都没有)**必须单独报出来**,' +
'⛔ 不许算进"不成功" —— 那不是效果差,是**根本没做/没记**。' +
'\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() },
},
async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)),
......
......@@ -16,6 +16,7 @@ import {
ListAssignmentsResponseDto,
AssignmentDetailResponseDto,
ListAgentsResponseDto,
AgentWorkloadResponseDto,
RefillProposalRequestDto,
RevokeAssignmentResponseDto,
SetAssignmentBenefitRequestDto,
......@@ -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()
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 201, type: CreateAssignmentResponseDto })
......@@ -100,10 +121,17 @@ export class AssignmentController {
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Query('mine') mine?: string,
@Query('limit') limit?: string,
/// 游标 = 上一页最后一条的 createdAt(ISO)。⛔ 不是 offset —— 批次一直在新增,
/// offset 翻页会重复或漏行(见 service 里 list 的注释)
@Query('before') before?: string,
) {
// 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')
......
......@@ -6,6 +6,7 @@ import {
ListAssignmentsResponseSchema,
ListAgentsResponseSchema,
RevokeAssignmentResponseSchema,
AgentWorkloadResponseSchema,
RefillProposalRequestSchema,
SetAssignmentBenefitRequestSchema,
SetAssignmentBenefitResponseSchema,
......@@ -16,6 +17,7 @@ export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentRe
export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {}
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
export class AgentWorkloadResponseDto extends createZodDto(AgentWorkloadResponseSchema) {}
export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {}
export class RefillProposalRequestDto extends createZodDto(RefillProposalRequestSchema) {}
export class SetAssignmentBenefitRequestDto extends createZodDto(SetAssignmentBenefitRequestSchema) {}
......
......@@ -7,11 +7,14 @@ import {
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
ABANDON_REASON_META,
type AbandonReason,
ASSIGNMENT_ITEMS_HARD_LIMIT,
EXECUTION_OUTCOME_META,
Permission,
PlanEventReason,
PlanEventType,
type AgentWorkloadResponse,
RELEASE_REASON_META,
REVOKE_WINDOW_MINUTES,
TEMPERATURE_META,
......@@ -29,6 +32,7 @@ import {
type SetAssignmentBenefitResponse,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { AgentRosterService } from './agent-roster.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder';
import { assertAssignable } from './claim-guard';
......@@ -54,6 +58,13 @@ const UPDATE_CHUNK = 500;
/// 已退回的行归到这个假 userId 下暂存 —— 退回后 assignee 已清空,摊到某个真人头上会摊错。
const RELEASED_BUCKET = '__released__';
/**
* 「通话记录」一次最多回多少条。
* ⚠️ 一批能有两三百人,而这块是**抽屉里的明细**不是导出 —— 200 条已经远超一屏能看的量。
* ⛔ 超了必须置 `recordsTruncated`,不许静默截断(主管会以为"就这些")。
*/
const OUTCOME_RECORD_LIMIT = 200;
/// 批次福利文本(attributes.benefit.text)。⚠️ 别在多处 `as any` 解 JSON,解错了不会报错
/**
* 「这一单处理了没」—— 判据只看**池子状态**,不看客服写没写回访结果。
......@@ -139,7 +150,11 @@ export function readBenefitText(attributes: unknown): string | null {
export class PlanAssignmentService {
private readonly logger = new Logger(PlanAssignmentService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
/// ⛔ 名册只有这一个来源(T10)—— 跟踪与分配读同一份,别另查
private readonly roster: AgentRosterService,
) {}
/**
* 主管确认 → 批量落归属。**这是整条生产线上唯一的写动作**(T8)。
......@@ -558,6 +573,136 @@ export class PlanAssignmentService {
}
/**
* 本批**逐条**的通话结果 + 客服手写的纪要 —— 主管抽屉里「通话记录」那一块。
*
* 🔴 在此之前 `plan_executions.notes`(客服手填的电话纪要)**全线读不到**
* (2026-08-08 查证):`GET /plans/:id/executions` 接口一直在,但前端从没调过、
* MCP 也没开工具 —— 写进去就再没出来过。主管看到「不成功 1」只能知道有这么个数,
* 问不出"哪个患者、为什么"。
*
* ⚠️ 与 `outcomeStats` **同一口径**:每条单只取**最近一次**执行(`DISTINCT ON`)。
* ⛔ 别改成列全部执行:打了三次才约上,那是**一个**结果不是三个,
* 聚合的四个桶与这张明细必须能对上数(T14)。
* ⚠️ 只列**有结果的**那些 —— 「没有结果」的人压根没有 execution 行,
* 它的数在聚合里(noOutcome),⛔ 别在这里造空行冒充。
* ⚠️ 封顶 `LIMIT`:一批能有两三百人。截断了要在响应里说明(见 `truncated`),
* ⛔ 不许静默截断 —— 主管会以为"就这些"。
*
* 🔴 **表单里勾了什么就要带什么出来**(2026-08-08 产品追问「有选择的项没有展示吗」)。
* `outcome` 只是**一个**字段,客服提交时还填了:
* · `abandon_reasons` 放弃原因(多选)—— 只有「放弃」这一项带子选项
* · `inaccurate_treatments` 勾了「治疗机会判断不对」时必填的治疗 code
* · `scheduled_next_at` 约定下次回访的日期
* · `channel` 电话 / 企微 / 短信
* ⛔ 只回 outcome 等于把客服填的一半信息烂在库里 —— 那正是这块要解决的问题本身。
*/
private async outcomeRecords(
assignmentId: string,
limit: number,
): Promise<Array<{
planId: string;
patientName: string | null;
operatorUserId: string;
outcome: string;
channel: string;
abandonReasons: string[];
inaccurateTreatments: string[];
scheduledNextAt: string | null;
notes: string | null;
at: string;
}>> {
const rows = await this.prisma.$queryRaw<
Array<{
plan_id: string;
patient_name: string | null;
operator_user_id: string;
outcome: string;
channel: string;
abandon_reasons: string[] | null;
inaccurate_treatments: string[] | null;
scheduled_next_at: Date | null;
notes: string | null;
created_at: Date;
}>
>(Prisma.sql`
SELECT e.plan_id, p.name AS patient_name, e.operator_user_id, e.outcome, e.channel,
e.abandon_reasons, e.inaccurate_treatments, e.scheduled_next_at, e.notes, e.created_at
FROM (
SELECT DISTINCT ON (plan_id) plan_id, operator_user_id, outcome, channel,
abandon_reasons, inaccurate_treatments, scheduled_next_at, notes, created_at
FROM plan_executions
WHERE assignment_id = ${assignmentId}::uuid
ORDER BY plan_id, created_at DESC
) e
JOIN followup_plans fp ON fp.id = e.plan_id
LEFT JOIN patients p ON p.id = fp.patient_id
ORDER BY e.created_at DESC
LIMIT ${limit}`);
return rows.map((r) => ({
planId: r.plan_id,
patientName: r.patient_name,
operatorUserId: r.operator_user_id,
outcome: r.outcome,
channel: r.channel,
// ⚠️ PG 数组列没勾时是 `{}`(空数组)不是 null —— 两种都归空数组
abandonReasons: r.abandon_reasons ?? [],
inaccurateTreatments: r.inaccurate_treatments ?? [],
scheduledNextAt: r.scheduled_next_at?.toISOString() ?? null,
// 空字符串按"没写"处理 —— 界面上 '' 和 null 都该显示成"没留纪要"
notes: r.notes?.trim() ? r.notes : null,
at: r.created_at.toISOString(),
}));
}
/**
* 「约上」—— **批次列表**那一列(2026-08-07 主管工作台)。
*
* ⚠️ 与 `outcomeStats` 同一口径,只是**一次问一批**:列表一屏 20 行,
* 逐行调 outcomeStats 就是 20 次往返。⛔ 别在 map 里 await。
* ⚠️ 「约上」= EXECUTION_OUTCOME 里 `group='close'` 的那些(转化新预约 + **约定下次回访**)——
* 与 detail 的 `outcomes.success` **同一个定义**,⛔ 别在这里另立标准(只数转化会比详情页小,
* 而两处都写着"约上",主管对不上就只能猜哪个对)。
* ⚠️ 每条单只取**最近一次**执行结果(DISTINCT ON):同一条单可能打过好几通,
* 数行会把一个人算好几次。
*/
/**
* 每批**真的落了执行结果**的条数 —— 列表那一列「已处置」。
* ⚠️ 与 `agentStats[].done` 同一口径(有执行记录才算),各客服加起来对得上这个数。
* ⛔ 与 `progress.done`(池子口径)是两个问题,别互相替代 —— 见 schema 注释。
*/
private async handledByAssignment(ids: string[]): Promise<Map<string, number>> {
if (ids.length === 0) return new Map();
const rows = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint | number }>>(
Prisma.sql`
SELECT assignment_id, count(DISTINCT plan_id) AS n
FROM plan_executions
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
GROUP BY assignment_id`,
);
return new Map(rows.map((r) => [r.assignment_id, Number(r.n)]));
}
private async bookedByAssignment(ids: string[]): Promise<Map<string, number>> {
if (ids.length === 0) return new Map();
const closeOutcomes = (Object.keys(EXECUTION_OUTCOME_META) as ExecutionOutcome[]).filter(
(o) => EXECUTION_OUTCOME_META[o].group === 'close',
);
if (closeOutcomes.length === 0) return new Map();
const rows = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint | number }>>(
Prisma.sql`
SELECT assignment_id, count(*) AS n FROM (
SELECT DISTINCT ON (plan_id) plan_id, assignment_id, outcome
FROM plan_executions
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
ORDER BY plan_id, created_at DESC
) latest
WHERE outcome IN (${Prisma.join(closeOutcomes)})
GROUP BY assignment_id`,
);
return new Map(rows.map((r) => [r.assignment_id, Number(r.n)]));
}
/**
* 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
*
* ⚠️ **服务端唯一生成点**。⛔ 别让模型自己拼:措辞会在两轮之间漂,
......@@ -573,7 +718,19 @@ export class PlanAssignmentService {
const parts: string[] = [formatBatchTime(head.createdAt, tz)];
if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment));
if (c.temperature) {
parts.push(TEMPERATURE_META[c.temperature as TemperatureValue]?.zh ?? c.temperature);
/**
* 🔴 **历史值 `cold` 不许原样显示**(2026-08-07 主管工作台实测:列表上出现「充填治疗 · cold」)。
*
* 温度拆成六档之前,criteria 里存的是 `cold`(= 四个冷档的并集,见 COLD_TEMPERATURES),
* 而 `TEMPERATURE_META` 里**没有**这个键 —— 于是兜底把码原样吐了出来。
* ⛔ 助手那边花了一整轮才把码堵住(第 0 条「说人话」),批次名这里不能再漏一个。
* ⚠️ 翻成「窗口外」而不是列出四档:那批人当时**就是按整个冷端圈的**,
* 写成"1 年内/1–2 年/2–3 年/3 年以上"反而是编了一个当时不存在的精度。
*/
const t = c.temperature;
parts.push(
TEMPERATURE_META[t as TemperatureValue]?.zh ?? (t === 'cold' ? '窗口外' : t),
);
}
parts.push(`${planned} 人`);
if (agents > 0) parts.push(`${agents} 位客服`);
......@@ -591,27 +748,52 @@ export class PlanAssignmentService {
* planned/agents/released/expired 走**账本**(历史事实),inHand/done 走 followup_plans(当前状态)。
* 混用一个源必然出错 —— 见 ledgerStatsByAssignment 的红字。
*/
async list(scope: TenantScopeContext, createdBy?: string): Promise<ListAssignmentsResponse> {
const heads = await this.prisma.planAssignment.findMany({
where: {
async list(
scope: TenantScopeContext,
createdBy?: string,
/**
* 🔴 **游标分页**(2026-08-07 主管工作台)。
*
* ⚠️ 游标是 `createdAt` 的 ISO 串,⛔ 不是 offset:批次一直在新增,
* offset 分页会让第 2 页重复或漏掉第 1 页刚被顶下去的那条。
* ⚠️ 排序键与游标必须是**同一列**(createdAt desc / createdAt <)—— 写岔了会静默丢行。
* ⚠️ 同一毫秒可能建了两批(脚本批量分配),所以 `total` 单独 count,
* ⛔ 别用「取回条数 < limit」推断"到底了"。
*/
page?: { limit?: number; before?: string },
): Promise<ListAssignmentsResponse> {
const limit = Math.min(Math.max(page?.limit ?? 50, 1), 100);
const before = page?.before ? new Date(page.before) : null;
const where = {
hostId: scope.hostId,
tenantId: scope.tenantId,
...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
...(createdBy ? { createdBy } : {}),
},
orderBy: { createdAt: 'desc' },
take: 50,
});
if (heads.length === 0) return { items: [] };
...(before && !Number.isNaN(before.getTime()) ? { createdAt: { lt: before } } : {}),
};
const [heads, total] = await Promise.all([
this.prisma.planAssignment.findMany({ where, orderBy: { createdAt: 'desc' }, take: limit }),
// 总数不带游标 —— 它答的是「一共多少批」,分页只影响这一页取哪些
this.prisma.planAssignment.count({
where: { ...where, createdAt: undefined } as typeof where,
}),
]);
if (heads.length === 0) return { items: [], total, nextBefore: null };
const ids = heads.map((h) => h.id);
const [live, ledger, names, tz] = await Promise.all([
const [live, ledger, names, tz, booked, handled] = await Promise.all([
this.statsByAssignment(ids),
this.ledgerStatsByAssignment(ids),
this.resolveNames(scope, heads.map((h) => h.createdBy)),
this.hostTimezone(scope.hostId),
this.bookedByAssignment(ids),
this.handledByAssignment(ids),
]);
return {
total,
/// 下一页从这条之前开始;取满一页才可能还有,⛔ 但仍以 total 为准判断"到底了"
nextBefore:
heads.length === limit ? heads[heads.length - 1]!.createdAt.toISOString() : null,
items: heads.map((h) => {
const l = live.get(h.id) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 };
const counts = mergeStats(l, ledger.get(h.id));
......@@ -630,11 +812,210 @@ export class PlanAssignmentService {
// 当前状态两项 —— 与账本口径的四项分开(见 mergeStats)
inHand: l.inHand,
done: l.done,
/// 与 detail 的 `outcomes.success` 同一定义(见 bookedByAssignment)
booked: booked.get(h.id) ?? 0,
/// 执行口径的「已处置」—— 与 agentStats[].done 同源,⛔ 不是 done(池子口径)
handled: handled.get(h.id) ?? 0,
};
}),
};
}
/**
* 🔴 **团队现在什么状态** —— 主管工作台右栏(2026-08-07)。
*
* 回答文档里那三个问题的第三个:「团队现在什么状态」。
*
* ── 两种时间性混在一张表里,必须说清 ────────────────────────────
* · **在手 / 超期** —— **此刻**的状态(手上还压着多少、其中多少过了时效),与窗口无关;
* · **完成 / 退回 / 已处置 / 没动** —— **窗口内**发生的事(近 7 天 / 近 30 天)。
* ⚠️ 界面上必须写明这件事,否则主管会把「在手 62」当成"这 7 天分了 62 条"。
*
* ⚠️ 「超期」必须带上**且没约下次回访**这半句,与 detail 的 agentStats 同一条判据:
* 到期回收器刻意跳过 snoozedUntil 在未来的单(客服约了 6/10 回访,那之前不能收走),
* 不加守卫会把"打了电话、约好下次"的人显示成"压着单没动" —— 干得最好的那个被指责。
*
* ⚠️ 退回率**两个分母都给**:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
* 没动的数量本身是信号,只给一个百分比会把它藏起来(文档「三个必须先定的口径」)。
*
* ⛔ 名册**沿用 AgentRosterService**,不在这里另查一份:
* 分配时问「还能吃多少」、跟踪时问「手上压了多少」是同一份数据的两种读法(T10)。
* 另查一份必然口径漂移,而漂了不报错。
*/
async workload(
scope: TenantScopeContext,
clinicId: string,
windowDays: number,
): Promise<AgentWorkloadResponse> {
const days = Math.min(Math.max(Math.round(windowDays) || 7, 1), 90);
const since = new Date(Date.now() - days * 86_400_000);
const now = new Date();
// 名册 = 分配那边同一个来源(T10),⛔ 别另起一份
const roster = await this.roster.list(scope, clinicId);
const ids = roster.agents.map((a) => a.userId);
if (ids.length === 0) {
return { clinicId, windowDays: days, agents: [] };
}
/**
* 此刻在手 —— **只有这一列是"当前"**,其余全在窗口内(见 overdue 的红字)。
* ⚠️ 走 `followup_plans` 的当前状态:「手上压着多少」问的就是此刻。
*/
const live = await this.prisma.$queryRaw<Array<{ uid: string; in_hand: bigint }>>(Prisma.sql`
SELECT assignee_user_id AS uid, count(*) AS in_hand
FROM followup_plans
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND status = 'assigned'
AND superseded_at IS NULL
AND assignee_user_id IN (${Prisma.join(ids)})
GROUP BY assignee_user_id`);
/**
* 🔴 **窗口内超期** —— 这一列**必须走账本,⛔ 不能查"当前还压在手上且已过期"**。
*
* ── 为什么(2026-08-07 实测)──────────────────────────────────
* 到期回收器每 10 分钟扫一遍,过期的单当场被收回池子、status 不再是 assigned。
* 于是"当前超期"这个口径**结构上几乎永远是 0**:本地实测
* 账本里 `auto_release/assignment_expired` **378 条**,而当前在手已过期 **0 条**。
* 那一列摆上去就是个常年为 0 的死数,主管会以为团队从不超期。
* ⇒ 问的应该是「**这 N 天里有多少条到期没人动被收走了**」,那是账本上的事件。
*
* ⚠️ **归属要回捞**:auto_release 事件的 `assignee_user_id` 和 `actor_user_id` 都是 null
* (释放后无人归属、且是系统行为)—— 必须 join 回该单**到期前最后一次 assign**
* 才知道当时压在谁手上。⛔ 不回捞就只能算出一个"全院超期 378",落不到人头上。
*
* ⚠️ 还要**并上"仍在手且窗口内到期"**那一小撮:回收器关掉时(PAC_ASSIGNMENT_EXPIRY=off)
* 账本会是空的而单子堆在手上 —— 只查账本会显示 0,而真相是堆了一片。
* 两边按 plan_id 去重。
* ⚠️ 约了下次回访的**不算**:回收器刻意跳过它们,那是客服动过了的证据(与 detail 同判据)。
*/
const overdue = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT uid, count(DISTINCT plan_id) AS n FROM (
-- ① 窗口内被自动回收的(账本);归属回捞到到期前最后一次 assign
SELECT owner.assignee_user_id AS uid, e.plan_id
FROM plan_event_logs e
JOIN LATERAL (
SELECT a.assignee_user_id
FROM plan_event_logs a
WHERE a.plan_id = e.plan_id
AND a.event = ${PlanEventType.ASSIGN}
AND a.created_at <= e.created_at
ORDER BY a.created_at DESC
LIMIT 1
) owner ON TRUE
WHERE e.host_id = ${scope.hostId}::uuid
AND e.tenant_id = ${scope.tenantId}
AND e.event = ${PlanEventType.AUTO_RELEASE}
AND e.reason = ${PlanEventReason.ASSIGNMENT_EXPIRED}
AND e.created_at >= ${since}
UNION
-- ② 还压在手上、到期时刻落在窗口内、且没约下次(回收器关掉时只有这一半)
SELECT fp.assignee_user_id AS uid, fp.id AS plan_id
FROM followup_plans fp
WHERE fp.host_id = ${scope.hostId}::uuid
AND fp.tenant_id = ${scope.tenantId}
AND fp.status = 'assigned'
AND fp.superseded_at IS NULL
AND fp.assignment_expires_at >= ${since}
AND fp.assignment_expires_at < ${now}
AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= ${now})
) u
WHERE uid IN (${Prisma.join(ids)})
GROUP BY uid`);
/**
* 窗口内的「退回」—— 走**账本**(历史事实,永不变)。
* ⚠️ ⛔ 不能读 `followup_plans.release_reason`:那是**当前值**,重分时会被清成 NULL,
* 于是退过的单在重分后就查不到了(detail 那边已经踩过这个坑)。
* ⚠️ release 事件本身的 assignee 是 null(释放后无人归属),要归到**释放前的承接人**——
* `plan_event_logs.actor_user_id` 就是点退回的那个人。
*/
const released = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT actor_user_id AS uid, count(DISTINCT plan_id) AS n
FROM plan_event_logs
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND event = ${PlanEventType.RELEASE}
AND created_at >= ${since}
AND actor_user_id IN (${Prisma.join(ids)})
GROUP BY actor_user_id`);
/**
* 窗口内的「完成」= 这段时间里写过通话结果的条数(按人按单去重)。
* ⚠️ 用 `plan_executions` 而不是 plan 状态:状态是**此刻**的,回答不了"这 7 天做了多少"。
* ⚠️ 同一条单一天打两通只算一条(DISTINCT plan_id)。
*/
const done = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT operator_user_id AS uid, count(DISTINCT plan_id) AS n
FROM plan_executions
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND created_at >= ${since}
AND operator_user_id IN (${Prisma.join(ids)})
GROUP BY operator_user_id`);
/**
* 窗口内「分到了多少」—— 退回率那个"没动"要用它减。
* ⚠️ 走账本的 assign 事件(历史事实),⛔ 不数 followup_plans.assignment_id:
* 那一列会被下一次分配覆盖。
*/
const assigned = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT assignee_user_id AS uid, count(DISTINCT plan_id) AS n
FROM plan_event_logs
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND event = ${PlanEventType.ASSIGN}
AND created_at >= ${since}
AND assignee_user_id IN (${Prisma.join(ids)})
GROUP BY assignee_user_id`);
const num = (rows: Array<{ uid: string; n: bigint }>) =>
new Map(rows.map((r) => [r.uid, Number(r.n)]));
const relM = num(released);
const doneM = num(done);
const asgM = num(assigned);
const liveM = new Map(live.map((r) => [r.uid, Number(r.in_hand)]));
const ovdM = num(overdue);
const agents = roster.agents.map((a) => {
const inHand = liveM.get(a.userId) ?? 0;
const doneN = doneM.get(a.userId) ?? 0;
const relN = relM.get(a.userId) ?? 0;
// 已处置 = 写了结果的 + 退回的 —— 两者都是"这单他动过了"
const handled = doneN + relN;
// 没动 = 窗口内分到的 − 已处置。⚠️ 可能为负(分配在窗口外、处置在窗口内),兜 0
const idle = Math.max(0, (asgM.get(a.userId) ?? 0) - handled);
return {
userId: a.userId,
name: a.name,
inHand,
overdue: ovdM.get(a.userId) ?? 0,
done: doneN,
released: relN,
handled,
idle,
/**
* ⭐ 成品句子,⛔ 前端别自己拼 —— 退回率必须**两个分母都带**,
* 写在这里保证界面、助手、导出三处口径一致(T14 的"工具返回值"那一半)。
*/
rateNote: handled
? `退回 ${relN} / 已处置 ${handled} = ${((relN / handled) * 100).toFixed(1)}%` +
(idle ? `,另有 ${idle} 条没动` : '')
: idle
? `这 ${days} 天分到 ${idle} 条,一条都还没动`
: `这 ${days} 天没有分到新的`,
};
});
return {
clinicId,
windowDays: days,
agents,
};
}
/** 单批全貌:按客服拆 + 退回原因分布 + 未动过的条数 */
async detail(scope: TenantScopeContext, ref: string): Promise<AssignmentDetailResponse> {
// 短号也认(模型手里常常只有界面上那 8 位)—— 见 resolveAssignmentId
......@@ -721,12 +1102,33 @@ export class PlanAssignmentService {
}
const now = new Date();
/**
* 🔴 **本批里哪些单真的落了执行结果**(2026-08-07 产品定)。
*
* ⭐ 「已处置」按人拆时**必须用这个**,⛔ 不能用池子状态:
* `classifyPlanProgress` 的 `resolved` 是**引擎判定召回需求没了**(患者自己来了),
* 客服一根手指没动,却会被算进他的「已处置」—— 按批次看无所谓,
* 按人看就是**冤枉人也放过人**(该催的没催出来、没做的显示成做了)。
* ⚠️ 批次级的 `progress.done` 仍走池子状态(它答的是"这批还剩多少活",是另一个问题),
* ⛔ 两者别互相替代 —— schema 里各自写清答的是什么。
*/
const executed = new Set(
planIds.length
? (
await this.prisma.planExecution.groupBy({
by: ['planId'],
where: { planId: { in: planIds }, tenantId: scope.tenantId },
})
).map((r) => r.planId)
: [],
);
const byAgent = new Map<string, AssignmentAgentStat>();
const reasonCount = new Map<string, number>();
const touch = (key: string): AssignmentAgentStat => {
let a = byAgent.get(key);
if (!a) {
a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, overdue: 0 };
a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, overdue: 0, done: 0 };
byAgent.set(key, a);
}
return a;
......@@ -760,6 +1162,16 @@ export class PlanAssignmentService {
// ⚠️ 走账本名册时**不再从这里累加 planned/released** —— 上面已经按账本记全了,
// 再加一遍就是双计。这里只负责叠加**当前状态**(在手 / 超期)。
if (!ledgerRoster) a.planned++;
/**
* 🔴 **按人的「已处置」= 这条单真的落了执行结果**(2026-08-07 产品定)。
*
* ⛔ **不能用 `classifyPlanProgress`**:它的 `resolved` 是引擎判定召回需求没了
* (患者自己来了),客服没动过 —— 按人算会把"没做"显示成"做了"。
* 按批次看那个口径没问题(答的是"这批还剩多少活"),按人看就是冤枉人。
* ⚠️ 于是各客服的 done 加起来**不等于** `progress.done` —— 这是**刻意的**,
* 两个数答的不是同一个问题;⛔ 别为了"能加得上"把它改回池子状态。
*/
if (executed.has(p.id)) a.done++;
if (p.status === 'assigned') {
a.inHand++;
// 🔴 「且没约下次回访」这半句不能少。到期回收器刻意跳过 snoozedUntil 在未来的单
......@@ -783,10 +1195,12 @@ export class PlanAssignmentService {
// 账本口径的计数(planned/agents/released/expired)+ 宿主时区(批次名要用)。
// ⚠️ 回落用的"老口径"就地从 plans / byAgent 算,⛔ 不再调 statsByAssignment ——
// 那会为同一批数据再发一次一模一样的查询。
const [ledger, tz, outcomeCounts] = await Promise.all([
const [ledger, tz, outcomeCounts, outcomeRows] = await Promise.all([
this.ledgerStatsByAssignment([id]),
this.hostTimezone(scope.hostId),
this.outcomeStats(id),
// +1 只为判断"还有没有更多",返回时切掉 —— ⛔ 别为了这个再发一次 count 查询
this.outcomeRecords(id, OUTCOME_RECORD_LIMIT + 1),
]);
const counts = mergeStats(
{
......@@ -881,24 +1295,64 @@ export class PlanAssignmentService {
keep: keepCount,
noOutcome,
byOutcome,
note:
withOutcome === 0
? `本批 ${counts.planned} 人**还没有任何通话结果**` +
(reassigned ? `(另有 ${reassigned} 人已被后面的批次挑走)` : '') +
`。⚠️ 这说明的是"还没做/没记",⛔ 不是"效果不好" —— 别据此评价召回质量。`
: `打过并记了结果的 ${withOutcome} 人:成功 ${success}、不成功 ${failed}、` +
`打了没进展 ${keepCount};还有 ${noOutcome} 人一次结果都没有。` +
(failed
? `不成功的原因:${byOutcome.filter((o) => o.group === 'give_up').map((o) => `${o.labelZh} ${o.n}`).join('、')}。`
: '') +
`⚠️ 「成功」含**约定下次回访**(约到下次也是有效推进),⛔ 不等于都成交了。` +
(withOutcome < 50
? `⚠️ 只有 ${withOutcome} 个样本,**不要算成功率、不要画图**,样本量不足。`
: ''),
/**
* 逐条明细 —— 主管问「哪个患者、为什么」时唯一答得上的地方。
* ⚠️ `records.length` 与聚合的四个桶必须对得上:
* `records.length === success + failed + keep`(没结果的没有行)。
*/
records: outcomeRows.slice(0, OUTCOME_RECORD_LIMIT).map((r) => ({
planId: r.planId,
patientName: r.patientName,
operatorUserId: r.operatorUserId,
operatorName: null as string | null, // 下面统一填(名字要等 resolveNames)
outcome: r.outcome,
labelZh: EXECUTION_OUTCOME_META[r.outcome as ExecutionOutcome]?.labelZh ?? r.outcome,
group: EXECUTION_OUTCOME_META[r.outcome as ExecutionOutcome]?.group ?? 'keep',
channel: r.channel,
/**
* 放弃原因(多选)—— ⚠️ **中文一并给**,⛔ 别只回 code。
* `out_of_service_area` 这种字符串扔给界面和助手,两边都得各自维护一张翻译表,
* 而这张表已经在 `ABANDON_REASON_META` 里了(T14:口径只有一份)。
* ⚠️ 未知 code **原样露出**,⛔ 不静默吞掉 —— 宁可看到生码也不能少一条原因。
*/
abandonReasons: r.abandonReasons.map((k) => ({
reason: k,
labelZh: ABANDON_REASON_META[k as AbandonReason]?.labelZh ?? k,
})),
/// 勾了「治疗机会判断不对」时选的治疗;同样给中文
inaccurateTreatments: r.inaccurateTreatments.map((c) => ({
code: c,
labelZh: potentialTreatmentCardLabel(c),
})),
scheduledNextAt: r.scheduledNextAt,
notes: r.notes,
at: r.at,
})),
/// ⚠️ 截断了必须说出来(⛔ 不许静默截断:主管会以为"就这些")
recordsTruncated: outcomeRows.length > OUTCOME_RECORD_LIMIT,
/**
* 🔴 这里**曾经有一个 `note` 成品句子,2026-08-08 产品要求删掉**。
*
* 它把两种东西焊在了一根字符串里:
* ① 给人看的数(「打过并记了结果的 1 人:成功 1、不成功 0…」)
* ② 给**模型**看的指令(「⛔ 不等于都成交了」「**不要算成功率、不要画图**」)
* 而 MCP 工具说明里还写着「照抄 `outcomes.note` 最稳」——
* 于是模型一字不落地贴进对话框:主管看到的是一段带 `**` 和 ⛔ 的内部指令。
*
* ⛔ 别再加回来。护栏该待的地方是**工具说明**(那边本来就写全了:
* 样本 <50 说"样本量不足"、noOutcome 单独报、success 含约定下次回访),
* 数留在结构化字段里由模型自己组织语言 —— 两者混进一个 string 必然漏出去。
*/
};
const names = await this.resolveNames(scope, [head.createdBy, ...byAgent.keys()]);
const names = await this.resolveNames(scope, [
head.createdBy,
...byAgent.keys(),
// 执行人可能已经不在本批的客服里了(退回后被别人捞去打的)—— 名字也得解析
...outcomeRows.map((r) => r.operatorUserId),
]);
for (const a of byAgent.values()) a.name = names.get(a.userId) ?? null;
for (const r of outcomes.records) r.operatorName = names.get(r.operatorUserId) ?? null;
return {
id: head.id,
......@@ -920,6 +1374,11 @@ export class PlanAssignmentService {
progress,
done: progress.done,
inHand: plans.filter((p) => p.status === 'assigned').length,
/// ⭐ 与列表那一列**同一个数**:列表走 bookedByAssignment,这里直接用 outcomes.success ——
/// 两处都是 `group='close'` 的合计,⛔ 别让它们分叉(界面上都叫「约上」)。
booked: outcomes.success,
/// 执行口径:本批真的落了通话结果的条数(= agentStats[].done 之和)
handled: executed.size,
agentStats: [...byAgent.values()].filter((a) => a.userId !== RELEASED_BUCKET),
releaseReasons: [...reasonCount]
.sort((a, b) => b[1] - a[1])
......
......@@ -22,6 +22,22 @@ const SCOPE = {
} as never;
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: {
/** 现在**还归本批**的 plan(重分走的人不在这里 —— 那正是问题所在) */
plans?: Array<{ id: string; status: string; assigneeUserId: string | null; releaseReason: string | null }>;
......@@ -34,8 +50,8 @@ function makePrisma(opts: {
events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>;
}) {
const plans = opts.plans ?? [];
const queryRaw = jest.fn(async () =>
opts.ledger
const queryRaw = jest.fn(async (q: unknown) =>
sqlKind(q) === 'ledger' && opts.ledger
? [{
assignment_id: BATCH,
planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents),
......@@ -69,6 +85,9 @@ function makePrisma(opts: {
})),
),
},
// 「已处置」按人拆走**执行口径**(有通话结果才算);空 = 这批没回写过结果,
// ⚠️ 那是生产常态(回写率约 11%),⛔ 别在 stub 里编几条让数字好看
planExecution: { groupBy: jest.fn(async () => []) },
planEventLog: {
// ⚠️ 按 where 分岔:带 assignmentId 的是「本批全量事件」(新路径),
// 带 planId 的是老批次回落路径 —— 后者在本 spec 里一律为空。
......@@ -229,17 +248,33 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () =>
dist: Array<{ outcome: string; n: number }>,
) => {
const { prisma } = makePrisma({ plans: [], ledger });
let call = 0;
(prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest.fn(async () => {
call += 1;
return call === 1
? [{
// ⚠️ 认 SQL 不认次序(见 sqlKind 的注释:按次序分派被新增查询撞散过一次)
(prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest.fn(async (q: unknown) => {
switch (sqlKind(q)) {
case 'ledger':
return [{
assignment_id: BATCH,
planned: BigInt(ledger.planned), agents: BigInt(ledger.agents),
released: BigInt(ledger.released), expired: BigInt(ledger.expired),
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;
};
......@@ -259,8 +294,12 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () =>
expect(o.failed).toBe(5); // 4 + 1
expect(o.keep).toBe(5);
expect(o.noOutcome).toBe(5); // 20 − 15
expect(o.note).toContain('明确拒绝 4');
expect(o.note).toContain('已在外院治疗 1');
// ⚠️ 原来是在 `note` 那句成品句子里找「明确拒绝 4」——note 已删(见下方那条测试),
// 改成查 `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);
});
......@@ -292,32 +331,66 @@ describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () =>
expect(o.noOutcome).toBe(9);
});
test('⭐ 一条结果都没有时,note 要明说"不是效果不好"', async () => {
/**
* ⚠️ 这三条原来断言的是 `outcomes.note` 那句成品句子的**措辞**。
* 2026-08-08 该字段删掉了(它把给模型的指令混进了给人看的数,模型照抄后
* 「不要算成功率、不要画图」直接贴进了主管的对话框),
* 护栏改由 MCP 工具说明承担 —— 所以断言跟着落到**结构**上:
* 措辞会改,而"哪个结果算进哪个桶"是不能变的。
*/
test('⭐ 一条结果都没有 → 全进 noOutcome,⛔ 不许混进 failed', async () => {
const svc = await build(
withOutcomes({ planned: 8, agents: 1, released: 0, expired: 0, revoked: 0 }, []),
);
const o = (await svc.detail(SCOPE, BATCH)).outcomes;
expect(o.noOutcome).toBe(8);
expect(o.note).toContain('还没有任何通话结果');
expect(o.note).toContain('不是"效果不好"');
// 🔴 「没做」⛔ 不是「做了没成」—— 混进 failed 会把执行问题读成召回问题
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(
withOutcomes({ planned: 20, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'success_appointed', n: 2 },
withOutcomes({ planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ 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(
withOutcomes({ planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'scheduled_next', n: 2 },
withOutcomes({ planned: 20, agents: 2, released: 0, expired: 0, revoked: 0 }, [
{ 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 () => {
......
......@@ -55,6 +55,9 @@ function makePrisma(opts: {
),
},
planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) },
// 「已处置」按人拆走**执行口径**(有通话结果才算)——空 = 这批一条结果都没回写,
// ⚠️ 那正是生产的常态(回写率约 11%),⛔ 别为了让数字好看在 stub 里编几条
planExecution: { groupBy: jest.fn(async () => []) },
// 批次名要按**宿主时区**格式化(主管说的"今天下午那批"是他墙上的时间)
host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
// ⚠️ 账本口径的计数走 $queryRaw。这里返回空 = 模拟「**老批次**」(账本里没有批次号),
......
......@@ -36,6 +36,37 @@ function EntryResolver() {
useEffect(() => {
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 () => {
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 {
/* 无障碍:不飞,直接不出现(移交本身仍照常发生,只是没有这段演出) */
.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';
import type {
AgentWorkloadResponse,
CreateAssignmentRequest,
CreateAssignmentResponse,
ListAgentsResponse,
......@@ -29,8 +30,29 @@ export const assignmentsApi = {
create: (body: CreateAssignmentRequest) =>
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) =>
api.get<AssignmentDetailResponse>(`/pac/v1/plans/assignments/${encodeURIComponent(id)}`),
......
......@@ -53,10 +53,28 @@ const HUE: Record<TempKey, { dot: string; text: string }> = {
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 =
'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]';
......@@ -66,6 +84,13 @@ export function PoolMatrix({
data,
selected,
onPick,
/**
* 外层容器样式覆盖。
* ⚠️ 默认 `w-[560px]` 是给**左栏那个 popover** 的(浮层要贴着按钮,不能撑满屏);
* 主管工作台的 modal 要它填满,传 `w-full` 覆盖。
* ⛔ 别把默认宽度删掉改成永远 w-full —— popover 会跟着变成整屏宽。
*/
className,
}: {
/**
* 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。
......@@ -77,6 +102,7 @@ export function PoolMatrix({
data: PoolMatrixData;
/** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null;
className?: string;
/**
* 点格子 → 初选完成,把这一格的人群交出去。
* ⚠️ 带 `rect`(该格子的视口矩形):数据流动画的**起点**。
......@@ -92,7 +118,7 @@ export function PoolMatrix({
}) {
const showUnknown = data.unknownTotal > 0;
return (
<Card className="w-[560px] border-0 shadow-none">
<Card className={cn('w-[560px] border-0 shadow-none', className)}>
{/*
⛔ **不放列合计**。列合计 = 该列 8 行相加,而**一个人可以同时出现在多行**
(有几个潜在治疗就占几行,实测人均 1.37 个)。于是六列一加会**大于**池子总人数
......
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { X } from 'lucide-react';
import type { AssignmentBrief, AssignmentDetailResponse } from '@pac/types';
import { RELEASE_REASON_META, type ReleaseReason } from '@pac/types';
import { assignmentsApi } from '@/components/plans/assignments-api';
import { cn } from '@/lib/utils';
/** 一页拉多少 —— 设计稿是滚到底继续加载,一屏 12 行左右 */
const PAGE = 20;
/**
* 「没动」标琥珀的阈值(占本批/本人分到量的比例)。
* ⚠️ **列表和抽屉共用这一个常量** —— 各写各的必然漂,而漂了主管只会以为某一处算错了。
*/
const IDLE_HEAVY = 0.2;
/**
* 「还在跑的」判据 —— **时效还没到,或还有没人动过的单**。
*
* ⚠️ 这是**前端算的**,因为它是一个展示分组不是业务状态:
* 服务端的 `status` 只有 confirmed/revoked,没有"跑没跑完"这个概念,
* 而这个分组的意义纯粹是「哪几批还来得及补救」。
* ⛔ 别为它去后端加一列 —— 口径一旦落库,改起来要迁移数据。
*/
function isRunning(b: AssignmentBrief, now: number): boolean {
if (b.status === 'revoked') return false;
return new Date(b.expiresAt).getTime() > now || b.inHand > 0;
}
/// 触达渠道中文。⚠️ 「电话」不在界面上标(默认值,每行都写就成噪音)—— 见调用处
const CHANNEL_ZH: Record<string, string> = {
phone: '电话',
wecom: '企微',
sms: '短信',
other: '其他',
};
/**
* 通话记录的时间戳 —— 「8/6 14:32」。
* ⚠️ 给**分钟**不是只给日期:同一天同一个客服可能打了好几条,只到天就分不出先后。
*/
function fmtWhen(iso: string) {
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 「跑了几天」—— 主管比批次之前必须先看年龄(跑三个月的天然比跑三天的好看) */
function ageDays(b: AssignmentBrief, now: number): number {
return Math.max(0, Math.floor((now - new Date(b.createdAt).getTime()) / 86_400_000));
}
/** 人群名 —— 从 label 里截「日期 · 人群」那两段;label 是服务端唯一生成点 */
function cohortOf(b: AssignmentBrief): { when: string; cohort: string } {
const parts = b.label.split(' · ');
return { when: parts[0] ?? '', cohort: parts.slice(1, 3).join(' · ') || '(未记录人群)' };
}
export function BatchTracking({ clinicId }: { clinicId: string | null }) {
const [items, setItems] = useState<AssignmentBrief[]>([]);
const [total, setTotal] = useState(0);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [scope, setScope] = useState<'running' | 'all'>('all');
const [openId, setOpenId] = useState<string | null>(null);
const now = useMemo(() => Date.now(), [items]);
/**
* 🔴 **在途守卫必须是 ref,⛔ 不能靠 `loading` 这个 state**(2026-08-07 实测)。
*
* IntersectionObserver 在滚动时会**连续回调**,而 `loading` 是 effect 建立那一刻的
* 快照 —— 它挡不住同一个 observer 的第二、第三次触发。结果是同一个 `cursor`
* 被追加了两遍,`items` 里出现重复批次 → React 报
* 「Encountered two children with the same key」,界面上那一批也真的画了两行。
* ⛔ 别改成把 `loading` 加进 IO 回调的闭包 —— 闭包捕获的还是旧值,一样挡不住。
*/
const inFlight = useRef(false);
const load = useCallback(async (before?: string) => {
if (inFlight.current) return;
inFlight.current = true;
setLoading(true);
setError(null);
try {
const r = await assignmentsApi.list({ limit: PAGE, ...(before ? { before } : {}) });
setTotal(r.total);
setCursor(r.nextBefore);
setItems((prev) => {
if (!before) return r.items;
/**
* ⚠️ 追加时**按 id 去重**。游标分页的边界天然会重复:
* `createdAt` 同一毫秒建的两批(脚本批量分配就会),`< before` 那一刀
* 切不干净。⛔ 别指望"守卫住了就不会重" —— 那是两回事。
*/
const seen = new Set(prev.map((b) => b.id));
return [...prev, ...r.items.filter((b) => !seen.has(b.id))];
});
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
inFlight.current = false;
setLoading(false);
}
}, []);
useEffect(() => {
setItems([]);
setCursor(null);
void load();
}, [clinicId, load]);
/**
* 滚到底继续加载。
* ⚠️ 用 IntersectionObserver 而不是 scroll 事件 —— 后者在这种内层滚动容器里
* 要自己算 scrollHeight,改布局就失效。
*/
/**
* 滚到底继续加载 —— **监听滚动容器的 scroll**,⛔ 不用 IntersectionObserver。
*
* 🔴 两条都踩过(2026-08-07):
* ① 哨兵是零高度 div → IO 对**零面积目标根本不回调**,连 observe() 后那次初始
* 回调都没有。表现是"滚到底永远不加载",而控制台一个字不报。
* ② 给了高度之后仍不触发 —— 实测在内置浏览器面板里 **IO 整个不工作**
* (挂到 `<h1>` 上也不回调)。那是环境问题,但它说明一件事:
* **这条路没法在我们的验证环境里证伪**,出问题只能靠用户反馈。
* ⇒ 换成 `scrollTop + clientHeight >= scrollHeight - 阈值`:没有任何隐式前提,
* 在哪儿都成立,也能在控制台一行验完。
* ⚠️ 仍要 `inFlight` 守卫:scroll 事件比 IO 密集得多,不挡住会连发好几页。
*/
const scroller = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scroller.current;
if (!el || !cursor || scope === 'running') return;
const onScroll = () => {
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 160) void load(cursor);
};
el.addEventListener('scroll', onScroll, { passive: true });
// 首屏没撑满时也要能继续拉(否则永远等不到一次 scroll)
onScroll();
return () => el.removeEventListener('scroll', onScroll);
}, [cursor, scope, load]);
const running = items.filter((b) => isRunning(b, now));
const shown = scope === 'running' ? running : items;
/**
* 按月分组(「还在跑的」单独一组排最前)。
* ⚠️ 分组只在「全部」视图里做 —— 「还在跑的」本身就是一组,再按月切没有意义。
*/
const groups = useMemo(() => {
const out: Array<{ key: string; note: string; rows: AssignmentBrief[] }> = [];
if (scope === 'running') {
return [{ key: '还在跑的', note: '时效未到、或还有没人动过的单', rows: shown }];
}
const runIds = new Set(running.map((b) => b.id));
const run = shown.filter((b) => runIds.has(b.id));
if (run.length) out.push({ key: '还在跑的', note: '时效未到、或还有没人动过的单', rows: run });
let lastKey = '';
for (const b of shown) {
if (runIds.has(b.id)) continue;
const d = new Date(b.createdAt);
const key = `${d.getMonth() + 1} 月`;
if (key !== lastKey) {
out.push({ key, note: '已经结束,只作回顾', rows: [] });
lastKey = key;
}
out[out.length - 1]!.rows.push(b);
}
return out.filter((g) => g.rows.length > 0);
}, [shown, running, scope]);
return (
<div className="relative 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="text-[11px] text-slate-400">点一行看这批具体怎么样</span>
<span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5">
<button
type="button"
onClick={() => setScope('running')}
className={cn(
'nums rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
scope === 'running'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
还在跑的 {running.length}
</button>
<button
type="button"
onClick={() => setScope('all')}
className={cn(
'nums rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
scope === 'all'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
全部 {total}
</button>
</span>
</div>
<div ref={scroller} 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>
) : items.length === 0 && loading ? (
<div className="space-y-2 p-3.5">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-slate-100" />
))}
</div>
) : items.length === 0 ? (
<p className="px-3.5 py-10 text-center text-[12px] text-slate-400">还没有分过批次</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>
{['条数', '已处置', '没动', '退回', '到期回池', '约上'].map((h) => (
<th
key={h}
className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{groups.map((g) => (
<BatchGroup
key={g.key}
group={g}
now={now}
openId={openId}
onOpen={setOpenId}
/>
))}
</tbody>
</table>
)}
{items.length > 0 && (
<div className="flex items-center gap-2 px-3.5 pb-1.5 pt-2.5">
<span className="h-px flex-1 bg-slate-50" />
<span className="nums text-[10.5px] text-slate-400">
{scope === 'running'
? `还在跑的 ${running.length} 批已全部显示`
: cursor
? `已显示 ${items.length} / ${total} 批 · 往下滚动继续加载`
: `已显示全部 ${items.length} 批`}
</span>
<span className="h-px flex-1 bg-slate-50" />
</div>
)}
</div>
{openId && <BatchDrawer id={openId} onClose={() => setOpenId(null)} />}
</div>
);
}
function BatchGroup({
group,
now,
openId,
onOpen,
}: {
group: { key: string; note: string; rows: AssignmentBrief[] };
now: number;
openId: string | null;
onOpen: (id: string) => void;
}) {
return (
<>
<tr>
<td colSpan={7} className="border-t border-slate-100 bg-white px-3.5 py-1.5">
<span className="text-[11px] font-semibold text-slate-600">{group.key}</span>
<span className="ml-1.5 text-[10.5px] text-slate-400">{group.note}</span>
</td>
</tr>
{group.rows.map((b) => {
const { when, cohort } = cohortOf(b);
// 「没动」占比高的标出来 —— 那是本批最值得先看的一格
const heavy = b.planned > 0 && b.inHand / b.planned > IDLE_HEAVY;
return (
<tr
key={b.id}
onClick={() => onOpen(b.id)}
className={cn(
'cursor-pointer border-t border-slate-50 hover:bg-slate-50',
openId === b.id && 'bg-brand-50 hover:bg-brand-50',
)}
>
<td className="px-3.5 py-2">
<div className="flex items-baseline gap-2 whitespace-nowrap">
<span className="nums text-[11.5px] text-slate-500">{when}</span>
<span className="font-medium text-slate-900">{cohort}</span>
<span className="nums text-[11px] text-slate-400">
跑了 {ageDays(b, now)} 天 · {b.agents} 位客服
</span>
{b.status === 'revoked' && (
<span className="text-[10.5px] text-slate-400">已撤销</span>
)}
</div>
</td>
<td className="nums px-2.5 py-2 text-right">{b.planned}</td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{b.handled}</td>
<td
className={cn(
'nums px-2.5 py-2 text-right',
heavy ? 'font-semibold text-amber-700' : 'text-slate-500',
)}
>
{b.inHand}
</td>
<td className="nums px-2.5 py-2 text-right text-rose-600">{b.released}</td>
<td className="nums px-2.5 py-2 text-right text-amber-700">{b.expired}</td>
<td className="nums px-2.5 py-2 text-right text-slate-900">{b.booked}</td>
</tr>
);
})}
</>
);
}
/**
* 单批下钻 —— 按客服拆 + 退回原因分布。
*
* ⚠️ 抽屉盖在**表格上**而不是整页:主管的上下文是"我在看这一列批次",
* 盖住整页他会失去位置感(设计稿也是这么做的 top:41px 起)。
*/
function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
const [d, setD] = useState<AssignmentDetailResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setD(null);
setError(null);
assignmentsApi
.detail(id)
.then((r) => alive && setD(r))
.catch((e) => alive && setError(e instanceof Error ? e.message : '加载失败'));
return () => {
alive = false;
};
}, [id]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
const maxReason = Math.max(1, ...(d?.releaseReasons ?? []).map((r) => r.n));
return (
<>
{/*
🔴 **z-20 不能省**(2026-08-07 实测):表格的 `thead` 是 `sticky z-10`,
抽屉不给层级的话,那一排列头(已处置 / 没动 / 退回 / 到期回池 / 约上)
会**浮在抽屉标题上面**,跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了,
实际是层级问题。⛔ 别去改 thead 的 sticky(那是列表滚动要用的)。
⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度:抽屉盖住表格但**不盖标题**,
主管要始终看得见"我还在看批次列表"。
*/}
<div
className="absolute inset-x-0 bottom-0 top-[3.25rem] z-20 bg-slate-900/10"
onClick={onClose}
/>
<aside className="absolute bottom-0 right-0 top-[3.25rem] z-20 flex w-[26rem] max-w-full flex-col overflow-hidden border-l border-slate-100 bg-white shadow-[-12px_0_24px_-6px_rgba(15,23,42,0.10)]">
<div className="flex flex-none items-start gap-2.5 border-b border-slate-50 bg-brand-50 px-3.5 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-slate-900">
{d?.label ?? '加载中…'}
</div>
{d && (
<div className="nums mt-0.5 text-[11px] text-slate-600">
已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 到期回池 {d.expired} ·
约上 {d.booked}
</div>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="flex-none rounded-md p-1 text-slate-500 hover:bg-brand-100 hover:text-brand-700"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto px-3.5 pb-4 pt-3">
{error ? (
<p className="py-6 text-center text-[12px] text-rose-600">{error}</p>
) : !d ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-5 animate-pulse rounded bg-slate-100" />
))}
</div>
) : (
<>
<div className="mb-1.5 text-[11.5px] font-semibold text-slate-700">按客服拆</div>
<table className="w-full border-collapse text-[12px] text-slate-700">
<thead>
<tr>
{['客服', '分到', '已处置', '没动', '退回'].map((h, i) => (
<th
key={h}
className={cn(
'py-1 text-[10.5px] font-medium text-slate-400',
i === 0 ? 'text-left' : 'px-1.5 text-right',
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{d.agentStats.map((a) => (
<tr key={a.userId} className="border-t border-slate-50">
<td className="py-1.5">{a.name ?? a.userId}</td>
<td className="nums px-1.5 py-1.5 text-right">{a.planned}</td>
<td className="nums px-1.5 py-1.5 text-right text-emerald-700">{a.done}</td>
{/* 🔴 判据必须与**外面那张表**一致(占比 > 20%),⛔ 不是"绝对值 > 4":
实测「分到 1 / 没动 1」(100%)在抽屉里是灰的、在列表里是琥珀的 ——
同一件事两种颜色,主管会以为其中一处算错了。 */}
<td
className={cn(
'nums px-1.5 py-1.5 text-right',
a.planned > 0 && a.inHand / a.planned > IDLE_HEAVY
? 'font-semibold text-amber-700'
: 'text-slate-500',
)}
>
{a.inHand}
</td>
<td className="nums py-1.5 text-right text-rose-600">{a.released}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 text-[11.5px] font-semibold text-slate-700">退回原因分布</div>
{/* 🔴 退回率两个分母都给 —— 只给百分比会把"没动"藏起来 */}
<div className="nums mt-0.5 text-[10.5px] text-slate-400">
退回 {d.released} / 已处置 {d.handled}
{d.handled > 0 ? ` = ${((d.released / d.handled) * 100).toFixed(1)}%` : ''},另有{' '}
{d.inHand} 条没动
</div>
<div className="mt-2 flex flex-col gap-1.5">
{(d.releaseReasons.length
? d.releaseReasons
: (Object.keys(RELEASE_REASON_META) as ReleaseReason[])
.filter((k) => !RELEASE_REASON_META[k].hidden)
.map((k) => ({ reason: k, labelZh: RELEASE_REASON_META[k].labelZh, n: 0 }))
).map((r) => (
<div
key={r.reason}
className="grid grid-cols-[7rem_1fr_1.4rem] items-center gap-2"
>
<span className="truncate text-[11px] text-slate-600">{r.labelZh}</span>
<span className="h-1.5 overflow-hidden rounded-full bg-slate-100">
<span
className="block h-full rounded-full bg-brand-600"
style={{ width: `${Math.round((r.n / maxReason) * 100)}%` }}
/>
</span>
<span className="nums text-right text-[11px] text-slate-500">{r.n}</span>
</div>
))}
</div>
{/* 🔴 这里原来贴的是 `d.outcomes.note` 那句成品句子,2026-08-08 随字段一起删。
它是写给**模型**看的(带 `**` 和 ⛔ 的指令),在界面上是原样渲染的乱码;
真正给人看的成效数在下面这四个桶里。⛔ 别再把模型指令当界面文案用。 */}
<div className="mt-4 text-[11.5px] font-semibold text-slate-700">通话成效</div>
{/* ⚠️ 四个桶**穷尽**(success + failed + keep + noOutcome === 条数,回归里锁着)——
所以四个都要摆出来,少一个就对不上数(T14)。
🔴 「没有结果」不是"效果差"是**根本没做/没记**,所以它跟前三个分开、
并且是唯一上色的:主管先要看的就是它。 */}
<div className="mt-1.5 grid grid-cols-4 gap-2 text-center">
{(
[
{ k: '成功', n: d.outcomes.success, c: 'text-emerald-700' },
{ k: '不成功', n: d.outcomes.failed, c: 'text-slate-600' },
{ k: '没进展', n: d.outcomes.keep, c: 'text-slate-600' },
{
k: '没有结果',
n: d.outcomes.noOutcome,
c: d.outcomes.noOutcome > 0 ? 'text-amber-700' : 'text-slate-300',
},
] as const
).map((o) => (
<div key={o.k} className="rounded-lg bg-slate-50 py-1.5">
<div className={cn('nums text-[15px] font-semibold', o.c)}>{o.n}</div>
<div className="text-[10.5px] text-slate-500">{o.k}</div>
</div>
))}
</div>
{/* ⚠️ 「成功」含**约定下次回访** —— 不写这句主管会读成"成交了这么多" */}
<div className="mt-1.5 text-[10.5px] leading-relaxed text-slate-400">
「成功」含约定下次回访,不等于都成交了;「没有结果」是这批里一次都没打/没记的。
</div>
{/*
🔴 **通话记录** —— 客服手写的电话纪要,2026-08-08 之前**全线读不到**:
`plan_executions.notes` 写进去就没有任何界面读回来过
(接口一直在,前端没调、助手也没工具)。主管看到「不成功 1」
只能知道有这么个数,问不出"哪个患者、为什么"。
⚠️ 条数与上面四个桶对得上:records.length === 成功 + 不成功 + 没进展
(「没有结果」的人压根没有执行行)—— ⛔ 别在这里补空行凑数。
⚠️ 结果的颜色跟着 `group` 走,与上面四个桶同一套,⛔ 别另配一版。
*/}
{d.outcomes.records.length > 0 && (
<>
<div className="mt-4 flex items-baseline gap-2">
<span className="text-[11.5px] font-semibold text-slate-700">通话记录</span>
<span className="text-[10.5px] text-slate-400">
{d.outcomes.records.length} 条 · 每条单只取最近一次
</span>
</div>
<ul className="mt-1.5 max-h-72 space-y-1 overflow-y-auto overscroll-contain">
{d.outcomes.records.map((r) => (
<li key={r.planId} className="rounded-lg bg-slate-50 px-2.5 py-1.5">
<div className="flex items-baseline gap-1.5">
<span className="truncate text-[11.5px] font-medium text-slate-700">
{r.patientName ?? '—'}
</span>
<span
className={cn(
'flex-none text-[10.5px]',
r.group === 'close'
? 'text-emerald-700'
: r.group === 'give_up'
? 'text-rose-600'
: 'text-slate-500',
)}
>
{r.labelZh}
</span>
{/* ⚠️ 渠道只在**不是电话**时标 —— 电话是默认,每行都写就成了噪音 */}
{r.channel !== 'phone' && (
<span className="flex-none rounded bg-slate-200/70 px-1 text-[10px] text-slate-600">
{CHANNEL_ZH[r.channel] ?? r.channel}
</span>
)}
<span className="ml-auto flex-none text-[10.5px] text-slate-400">
{r.operatorName ?? r.operatorUserId} · {fmtWhen(r.at)}
</span>
</div>
{/*
🔴 **客服勾的子选项也要摆出来**(2026-08-08 产品追问)。
`outcome` 只是一个字段,表单里还有放弃原因(多选)、
「判断不对」选的治疗、约的回访日期 —— 只显示 outcome
等于把他填的一半东西藏了。
⚠️ 三者**互斥地出现**(各自绑定不同 outcome),所以只渲染有值的那个,
⛔ 别给空的留占位行。
*/}
{(r.abandonReasons.length > 0 ||
r.inaccurateTreatments.length > 0 ||
r.scheduledNextAt) && (
<div className="mt-1 flex flex-wrap items-center gap-1">
{r.abandonReasons.map((a) => (
<span
key={a.reason}
className="rounded bg-rose-50 px-1.5 py-0.5 text-[10.5px] text-rose-700"
>
{a.labelZh}
</span>
))}
{/* ⚠️ 「判断不对」是**客服说算法判错了** —— 调算法要看的就是它,
所以单独标出治疗名,⛔ 别跟放弃原因混成一色 */}
{r.inaccurateTreatments.map((t) => (
<span
key={t.code}
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10.5px] text-amber-800"
>
判断不对:{t.labelZh}
</span>
))}
{r.scheduledNextAt && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10.5px] text-emerald-700">
{fmtWhen(r.scheduledNextAt)}
</span>
)}
</div>
)}
{/*
⭐ **纪要空着本身是信息**:结果填了、一个字没写,说明只点了个选项。
⛔ 别把这行整个省掉 —— 省掉之后"写了但很短"和"根本没写"看起来一样。
⚠️ `whitespace-pre-wrap`:客服是分行写的,压成一行就读不出条理。
*/}
<div
className={cn(
'mt-0.5 whitespace-pre-wrap text-[11px] leading-relaxed',
r.notes ? 'text-slate-600' : 'italic text-slate-300',
)}
>
{r.notes ?? '没留纪要'}
</div>
</li>
))}
</ul>
{/* ⛔ 截断不许不说 —— 主管会以为"就这些" */}
{d.outcomes.recordsTruncated && (
<div className="mt-1 text-[10.5px] text-amber-700">
只显示了最近 {d.outcomes.records.length} 条,这批还有更多通话记录。
</div>
)}
</>
)}
</>
)}
</div>
</aside>
</>
);
}
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { TEMPERATURE_META, TEMPERATURE_ORDER, type TemperatureValue } from '@pac/types';
import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix';
import { useAssistantStore } from '@/stores/assistant-store';
import { cn } from '@/lib/utils';
/**
* 「分一批新的」—— **两版**(设计稿 `newBatchMode` 那两个选项,产品要求都实现)。
*
* · `window` 矩阵窗口:居中 modal 套现有 `PoolMatrix`。改动最小,就是把左栏那个浮层搬过来。
* · `arcade` 里世界打地鼠:全屏传送门 + 深色场 + 敲一格 + 数据粒子飞向助手。
*
* ⛔ **两版挑完格子走的是同一条路**:`useAssistantStore.handoff()` ——
* 发数据流 → 粒子飞到助手 → 落地后自动开大窗并说那句话。
* ⚠️ 这条链路是现成的(左栏那个入口一直在用),⛔ 别在这儿另写一套移交:
* 移交的时序(动画多长、什么时候开窗)是舞台层的事,业务侧复制一份必然漂。
*/
export type NewBatchMode = 'window' | 'arcade';
type Picked = {
treatment: string;
treatmentZh: string;
temperature: TemperatureValue;
count: number;
rect: { x: number; y: number; w: number; h: number };
};
/** 两版共用的移交 —— ⛔ 别在各自分支里各写一遍 */
function handoff(c: Picked) {
const tempZh = TEMPERATURE_META[c.temperature].zh;
useAssistantStore.getState().handoff({
text: `帮我给「${c.treatmentZh} · ${tempZh}」这批患者出一份分配方案`,
from: c.rect,
count: c.count,
treatment: c.treatmentZh,
temperature: tempZh,
});
}
export function NewBatchOverlay({
mode,
clinicId,
onClose,
/** 「里世界」入场用的传送门圆心 = **触发按钮中心**的视口坐标;不给则从右上角进 */
origin,
}: {
mode: NewBatchMode;
clinicId: string | null;
onClose: () => void;
origin?: { x: number; y: number } | null;
}) {
const [matrix, setMatrix] = useState<PoolMatrixData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!clinicId) return;
let alive = true;
plansApi
.matrix(clinicId)
.then((r) => alive && setMatrix(r))
.catch((e) => alive && setError(e instanceof Error ? e.message : '取矩阵失败'));
return () => {
alive = false;
};
}, [clinicId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
const pick = useCallback(
(c: Picked) => {
onClose();
handoff(c);
},
[onClose],
);
if (mode === 'arcade') {
return (
<ArcadeMatrix
matrix={matrix}
error={error}
origin={origin ?? null}
onPick={pick}
onClose={onClose}
/>
);
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 p-4"
onClick={onClose}
>
<div
className="w-full max-w-[62rem] overflow-hidden rounded-xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-baseline gap-2 border-b border-slate-50 px-4 py-3">
<span className="text-[13.5px] font-semibold text-slate-900">分一批新的</span>
<span className="text-[11.5px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单
</span>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="ml-auto rounded-md p-1 text-slate-500 hover:bg-slate-100"
>
<X className="h-4 w-4" />
</button>
</div>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
<div className="overflow-x-auto p-4">
{error ? (
<p className="py-10 text-center text-[12px] text-rose-600">{error}</p>
) : matrix ? (
/* ⚠️ `w-full` 覆盖组件默认的 560px(那是给左栏 popover 的)——
不覆盖的话 modal 有 992px 宽而矩阵只占 560,右边一大片空白(实测)。
`min-w-[34rem]` 保证再窄也不把数字挤糊,由外层横向滚兜住。 */
<PoolMatrix data={matrix} selected={null} onPick={pick} className="w-full min-w-[34rem]" />
) : (
<div className="space-y-2 py-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-7 animate-pulse rounded bg-slate-100" />
))}
</div>
)}
</div>
</div>
</div>
);
}
/**
* 「里世界」打地鼠版。
*
* ⚠️ 三个技术点是**实测会静默失效**的,别省:
* ① `offset-path` 粒子飞行 Safari 16 才支持 —— 低版本不崩但完全没动效,
* 所以粒子**只是锦上添花**,移交本身不依赖它(`handoff` 立即发出)。
* ② 自定义光标的 data URI 在 Windows 上**上限 32×32**,超了整个 cursor 被忽略 →
* 锤子必须画在 32×32 里(设计稿给的是 34,会被丢掉)。
* ③ `prefers-reduced-motion` 下把飞行与入场动画全关掉。
*
* ⭐ 地洞的色相**只由列位置取样**,与表世界那条 amber→emerald→sky 同一条轴 ——
* ⛔ 数量不参与配色(与 pool-matrix 同一条纪律)。
*/
function ArcadeMatrix({
matrix,
error,
origin,
onPick,
onClose,
}: {
matrix: PoolMatrixData | null;
error: string | null;
origin: { x: number; y: number } | null;
onPick: (c: Picked) => void;
onClose: () => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [hit, setHit] = useState<string | null>(null);
const [leaving, setLeaving] = useState(false);
/**
* 传送门的几何:圆心(相对**幕布自己**的左上角)+ 要放大多少倍。
*
* 🔴 换掉了原来的 `clip-path: circle()` 方案,两个原因,都踩过:
* ① **定位不可靠**:clip-path 的 `at <x> <y>` 是按元素的 **reference box** 算的,
* 不是视口。`fixed` 元素的包含块只要被祖先的 transform / filter /
* backdrop-filter / contain 之类劫走,这个盒子就不再是视口 ——
* 于是圆心整体偏掉,而且**不报任何错**。产品连着两轮反馈"不是从鼠标处扩散的",
* 在开发面板里却量得分毫不差,就是这个差别。
* 现在改成 `absolute` + `left/top`,量的是**同一个盒子**(`rootRef` 的 rect),
* 偏不了 —— 见下面的 `useLayoutEffect`。
* ② **性能**:clip-path 不上合成层,那 0.5s 里每一帧都要重画整屏(含 48 个格子)。
* 现在幕布是一个 200×200 的小圆靠 `transform: scale()` 放大,
* 内容只做 `opacity` —— 全程合成层,**零重绘**。
* ⛔ 别图省事把幕布直接做成全屏大小再 scale:那会申请一张按未缩放尺寸算的纹理
* (2700² ≈ 28MB),小圆放大才是便宜的那条路。
*
* ⚠️ 必须在 `useLayoutEffect` 里量(不是 render 里读 `window`):
* 要的是幕布**实际**占的那个盒子,不是 `innerWidth/innerHeight` 这个假设。
* ⚠️ 布局副作用里 setState 会在**绘制前**同步补一次 render,所以不会闪一帧。
*/
const [portal, setPortal] = useState<{ x: number; y: number; s: number } | null>(null);
useLayoutEffect(() => {
const el = rootRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
// 没给圆心时从右上角进(那也是按钮所在的角)
const p = origin ?? { x: r.left + r.width * 0.9, y: r.top + r.height * 0.06 };
const x = p.x - r.left;
const y = p.y - r.top;
// 盖满 = 圆心到最远那个角的距离
const far = Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y));
setPortal({ x, y, s: far / PORTAL_DISC_R });
}, [origin]);
/**
* ⚠️ 幕布还没盖住之前,内容是 `opacity:0` 但**照样能点**(透明不等于穿透)——
* 那 0.36s 里手快点中一格就会盲发一批人。所以入场期间先关掉命中。
* ⛔ 别改用 `onAnimationEnd` 判断:格子自己也有动画、事件会冒泡上来,
* 而且 reduced-motion 下根本不触发。
*/
const [ready, setReady] = useState(false);
useEffect(() => {
const t = window.setTimeout(() => setReady(true), 360);
return () => window.clearTimeout(t);
}, []);
const leave = useCallback(() => {
setLeaving(true);
window.setTimeout(onClose, 380);
}, [onClose]);
const whack = (key: string, e: React.MouseEvent, c: Picked) => {
setHit(key);
// ⭐ 移交**立即发**,⛔ 不等动画:动画失败(Safari / reduced-motion)时也不能卡住业务
onPick({ ...c, rect: rectOf(e.currentTarget as HTMLElement) });
};
return (
/* ⚠️ 根节点**不能有背景** —— 传送门没张开的地方要露出下面的工作台,
"从按钮里钻进去"的因果关系全靠这个。 */
<div ref={rootRef} className="fixed inset-0 z-50 overflow-hidden">
{portal && (
<div
aria-hidden
className={leaving ? 'pac-portal-disc-out' : 'pac-portal-disc'}
style={
{ left: portal.x, top: portal.y, '--s': portal.s } as React.CSSProperties
}
/>
)}
<div
className={cn(
'absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 py-8',
leaving ? 'pac-portal-body-out' : 'pac-portal-body',
!ready && 'pointer-events-none',
)}
style={{
background:
'radial-gradient(120% 90% at 50% 12%, #0B2E7A 0%, #031C53 45%, #010F32 100%)',
}}
>
<div className="flex w-full max-w-[74rem] items-baseline gap-3">
<span className="text-[17px] font-semibold tracking-wide text-slate-50">
挑一格,把这批人交给助手
</span>
<span className="text-[12px] text-brand-200/60">
行是潜在治疗,列是召回窗口 · 敲下去就出确认单
</span>
<button
type="button"
onClick={leave}
className="ml-auto rounded-lg border border-brand-200/25 bg-white/5 px-2.5 py-1 text-[11.5px] text-brand-100 hover:bg-white/15 hover:text-white"
>
退回
</button>
</div>
{error ? (
<p className="text-[12.5px] text-rose-300">{error}</p>
) : !matrix ? (
<p className="text-[12.5px] text-brand-200/60">正在点亮地洞…</p>
) : (
<div className="w-full max-w-[74rem]">
{/* 列头 —— 六个档位的光点,色相与下面的地洞同源 */}
<div className="flex items-center pb-2.5">
<div className="w-[4.75rem] flex-none" />
<div className="flex flex-1 gap-2.5">
{TEMPERATURE_ORDER.map((t, i) => (
<div
key={t}
className="flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap text-[12px] font-semibold"
style={{ color: bandTint((i + 0.5) / TEMPERATURE_ORDER.length, 0.95) }}
>
<span
className="h-1.5 w-1.5 rounded-sm"
style={{
background: bandTint((i + 0.5) / TEMPERATURE_ORDER.length, 1),
boxShadow: `0 0 8px ${bandTint((i + 0.5) / TEMPERATURE_ORDER.length, 1)}`,
}}
/>
{TEMPERATURE_META[t].zh}
</div>
))}
</div>
</div>
<div className="flex items-start">
<div className="flex w-[4.75rem] flex-none flex-col gap-2.5">
{matrix.rows.map((r) => (
<div
key={r.key}
className="flex h-14 items-center text-[13px] font-medium text-slate-100/85"
>
{r.zh}
</div>
))}
</div>
<div
className="flex-1 rounded-2xl p-2.5"
style={{
background:
'linear-gradient(to right, rgba(252,211,77,.42), rgba(110,231,183,.34) 45%, rgba(125,211,252,.40))',
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.06)',
}}
>
<div className="flex flex-col gap-2.5">
{matrix.rows.map((row) => (
<div key={row.key} className="flex gap-2.5">
{TEMPERATURE_ORDER.map((t, ci) => {
const n = row.counts[t] ?? 0;
const key = `${row.key}-${t}`;
const tint = bandTint((ci + 0.5) / TEMPERATURE_ORDER.length, 0.34);
if (n === 0) {
return (
<span
key={t}
title="这一格没有人"
className="relative h-14 flex-1 rounded-xl"
style={{
background:
'radial-gradient(closest-side, rgba(2,10,32,.55), rgba(2,10,32,.18))',
boxShadow: 'inset 0 2px 6px rgba(0,0,0,.45)',
}}
/>
);
}
return (
<button
key={t}
type="button"
title={`${row.zh} · ${TEMPERATURE_META[t].zh} · ${n} 人`}
onClick={(e) =>
whack(key, e, {
treatment: row.key,
treatmentZh: row.zh,
temperature: t,
count: n,
rect: { x: 0, y: 0, w: 0, h: 0 },
})
}
className={cn(
'relative h-14 flex-1 rounded-xl border-0 text-[17px] font-semibold text-slate-50 transition-transform',
'hover:-translate-y-[3px] focus-visible:outline focus-visible:outline-2 focus-visible:outline-white',
hit === key && 'animate-[whack_.42s_ease-in_forwards]',
)}
style={{
cursor: HAMMER_CURSOR,
backgroundColor: tint,
backgroundImage:
'linear-gradient(180deg, rgba(255,255,255,.14), rgba(2,10,32,.20))',
boxShadow:
'inset 0 0 0 1px rgba(255,255,255,.14), 0 6px 14px -6px rgba(1,15,50,.8)',
textShadow: '0 1px 8px rgba(1,15,50,.6)',
}}
>
{n.toLocaleString()}
{hit === key && (
<span className="pointer-events-none absolute -inset-1.5 rounded-2xl border-2 border-white/90 animate-[ringOut_.5s_ease-out_forwards]" />
)}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
<p className="mt-3 text-[11.5px] leading-relaxed text-brand-200/50">
前两档按该治疗自己的周期算,后四档按诊断距今多久算。一个人有几个潜在治疗就出现在几行
—— 所以各行相加会大于池子总人数。
</p>
</div>
)}
</div>
</div>
);
}
/**
* 传送门幕布的**未缩放**半径(px),与 globals.css 的 `.pac-portal-disc` 尺寸一一对应。
* ⚠️ 改这里就要同步改那边的 `width/height/margin`,否则放大倍数算错、盖不满或过冲。
*/
const PORTAL_DISC_R = 100;
/** 元素在视口里的矩形 —— 移交动画的起点 */
function rectOf(el: HTMLElement) {
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, w: r.width, h: r.height };
}
/**
* 地洞色相 —— 沿 amber→emerald→sky 这条轴按**列中心**取样。
* ⛔ 数量不参与(与 pool-matrix 同一条纪律):否则"这格亮是因为久没来、还是因为人多"分不清。
*/
const STOPS: Array<[number, [number, number, number]]> = [
[0, [252, 211, 77]],
[0.45, [110, 231, 183]],
[1, [125, 211, 252]],
];
function bandTint(f: number, alpha: number): string {
let a = STOPS[0]!;
let b = STOPS[STOPS.length - 1]!;
for (let i = 0; i < STOPS.length - 1; i++) {
if (f >= STOPS[i]![0] && f <= STOPS[i + 1]![0]) {
a = STOPS[i]!;
b = STOPS[i + 1]!;
break;
}
}
const t = (f - a[0]) / (b[0] - a[0] || 1);
const c = a[1].map((v, i) => Math.round(v + (b[1][i]! - v) * t));
return `rgba(${c.join(',')},${alpha})`;
}
/**
* 锤子光标。
* 🔴 **必须画在 32×32 之内** —— Windows 上自定义光标超过 32×32 会被**整个忽略**
* (设计稿给的是 34×34,那份在 Windows 上等于没有光标效果)。
*/
const HAMMER_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">' +
'<g transform="rotate(-28 16 16)">' +
'<rect x="14" y="10" width="4" height="18" rx="2" fill="#DCE7FE" stroke="#01102F" stroke-width="1.6"/>' +
'<rect x="6" y="4" width="19" height="8.5" rx="2.5" fill="#F8FAFC" stroke="#01102F" stroke-width="1.6"/>' +
'<rect x="9" y="6" width="5" height="4.5" rx="1" fill="#85A9FA" opacity=".8"/>' +
'</g></svg>';
const HAMMER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(HAMMER_SVG)}") 12 8, pointer`;
'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 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
后端 `assign` 保留(见 T12,认领与分配同构),但**前端不给入口**
将来要放开客服主动性,是加回一个入口的事,不用改模型。
**分配入口就近召回池** —— 矩阵是池子的一个视图模式(列表 ⇄ 矩阵切换),不新开路由、不换心智。
主管本质也是客服,也要执行,割裂成两个页面会把他劈成两个身份。
> ### ⛔ 下面这段已被推翻(2026-08-04 产品评审),保留原文当病史
>
> **分配入口就近召回池** —— 矩阵是池子的一个视图模式(列表 ⇄ 矩阵切换),不新开路由、不换心智。
> 主管本质也是客服,也要执行,割裂成两个页面会把他劈成两个身份。
**改判:主管有一条独立路由 `/supervisor`(2026-08-04 评审定,08-07 落地)。**
推翻它的不是设计偏好,是需求方在同一场评审里讲了三遍的同一件事:
> 「就是**场景和思路不应该去混**……领导想的是我怎么去制定战略,
> 到你这儿人就是应该执行、应该打电话……**不搭嘎的事情混在一起**。」
> 「我觉得他[主管]应该是有一个他自己的 view,不是像现在这种跟话术混在一起、
> 跟一个患者明细混在一起。」
原教条错在**把"同一个人"当成了"同一个场景"**。主管确实也打电话——但那是他换一顶帽子之后的事,
不是同一屏里的事。旧写法的实际后果:他要出一版分配方案,得先进**某个患者**的详情页,
在话术和病历中间找到那个浮层。
⚠️ **没被推翻的那半句仍然作数**:主管本质也是客服。所以两个页面必须**双向**——
`/supervisor` 头部常驻「客服执行页 ↗」,⛔ 别做成有去无回。
⚠️ 权限没变:仍是 `PLAN_DISPATCH` 一把闸,与召回池 tab 同源。⛔ 别为新路由另立权限。
### T17 · 表设计保守立柱:会用来筛的才立柱,其余进 JSON
......
......@@ -254,12 +254,41 @@ export const AssignmentBriefSchema = z.object({
* 详见 `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 const ListAssignmentsResponseSchema = z.object({
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>;
......@@ -280,6 +309,12 @@ export const AssignmentAgentStatSchema = z.object({
* 以为她压着单没动,**实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
*/
overdue: z.number().int().describe('仍在手、已过时效、且没约下次回访'),
/**
* 这个客服在本批里已处置多少条。
* ⚠️ 判据与批次级的 `progress.done` **同一个函数**(classifyPlanProgress)——
* ⛔ 各客服的 done 加起来必须等于表头那个总数,主管一定会去加。
*/
done: z.number().int(),
});
export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>;
......@@ -327,8 +362,58 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
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>;
export type SheetAssignTo = z.infer<typeof SheetAssignToSchema>;
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
// =============================================================
......
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