Commit 19bd658f by luoqi

feat: 详情页显示分配单时效「还剩 N 天退回」

主管问"现在有时效了,是不是可以显示以前隐藏的自动回收时间" —— 查下来
**不是取消隐藏,是要接一条新的**:那个被隐藏的倒计时基于 `recycleAt`(认领的 24h 兜底,
PAC_PLAN_AUTO_RECYCLE 默认 off,**从未启用**,值几乎恒为 null),
而现在真正生效的是 `assignmentExpiresAt`(主管在确认单上定的 1-7 天,
AssignmentExpiryScheduler 每 10 分钟扫,到点回池记 assignment_expired)。
两个字段一直被混为一谈,详情接口**根本没返回后者**。

· 服务端 /plans/:id/full 补 assignmentExpiresAt;契约里给两个字段各写清适用场景。
· 前端 RecycleCountdown 换成 ExpiryBadge 并挂回头部,只在**单子还挂在人手上**时显示。
· ️ 粒度跟着换:原来是 HH:MM:SS(为 24h 设计),而时效是**天**级 ——
  3 天的单显示 71:59:58 没人读得出来。改成「还剩 2 天」/「还剩 5 小时」,
  最后 1 小时才精确到分钟;刷新从 1s 降到 30s(天级不需要每秒重渲染整页)。
· ️ 配色:最后一天转琥珀、最后一小时转红。 不更早变红 —— 时效本来就是几天,
  提前两天飘红会让客服对颜色脱敏,真到期时反而看不见。

本地实测(客服「位其蕾」):头部显示「还剩 3 天退回」,hover 出具体到期时刻。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 1409f6f5
...@@ -580,6 +580,7 @@ function serializePlan(plan: { ...@@ -580,6 +580,7 @@ function serializePlan(plan: {
assigneeUserId: string | null; assigneeUserId: string | null;
assignedAt: Date | null; assignedAt: Date | null;
recycleAt: Date | null; recycleAt: Date | null;
assignmentExpiresAt: Date | null;
snoozedUntil: Date | null; snoozedUntil: Date | null;
recallFeedback: string | null; recallFeedback: string | null;
recallFeedbackNote: string | null; recallFeedbackNote: string | null;
...@@ -614,6 +615,8 @@ function serializePlan(plan: { ...@@ -614,6 +615,8 @@ function serializePlan(plan: {
assigneeUserId: plan.assigneeUserId, assigneeUserId: plan.assigneeUserId,
assignedAt: plan.assignedAt?.toISOString() ?? null, assignedAt: plan.assignedAt?.toISOString() ?? null,
recycleAt: plan.recycleAt?.toISOString() ?? null, recycleAt: plan.recycleAt?.toISOString() ?? null,
/// ⭐ 分配单到期时刻(客服手上这单还剩多久);⚠️ 与 recycleAt 是两个机制,见 schema 注释
assignmentExpiresAt: plan.assignmentExpiresAt?.toISOString() ?? null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X" /// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X"
snoozedUntil: plan.snoozedUntil?.toISOString() ?? null, snoozedUntil: plan.snoozedUntil?.toISOString() ?? null,
/// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null /// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null
......
...@@ -177,6 +177,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -177,6 +177,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
assigneeUserId: real.plan?.assigneeUserId ?? null, assigneeUserId: real.plan?.assigneeUserId ?? null,
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now, assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null, recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null,
assignmentExpiresAt: real.plan?.assignmentExpiresAt
? new Date(real.plan.assignmentExpiresAt)
: null,
snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null, snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null,
recallFeedback: (real.plan?.recallFeedback ?? null) as 'up' | 'down' | null, recallFeedback: (real.plan?.recallFeedback ?? null) as 'up' | 'down' | null,
recallFeedbackNote: real.plan?.recallFeedbackNote ?? null, recallFeedbackNote: real.plan?.recallFeedbackNote ?? null,
......
...@@ -356,6 +356,8 @@ export const mockPlan = { ...@@ -356,6 +356,8 @@ export const mockPlan = {
assigneeUserId: 'usr_csliu' as string | null, assigneeUserId: 'usr_csliu' as string | null,
assignedAt: NOW, assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null, recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
// 分配单时效:mock 给 2 天后到期(真实值 1-7 天,由主管在确认单上定)
assignmentExpiresAt: new Date(NOW.getTime() + 2 * 24 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标 /// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: null as Date | null, snoozedUntil: null as Date | null,
/// 召回反馈(plan 级)— 标题栏拇指当前态 + 文字 /// 召回反馈(plan 级)— 标题栏拇指当前态 + 文字
......
...@@ -1241,7 +1241,9 @@ function TopBar({ ...@@ -1241,7 +1241,9 @@ function TopBar({
)} )}
</div> </div>
)} )}
{/* 回收倒计时已隐藏 —— 暂无自动回收机制(返池改为手动)。RecycleCountdown 组件保留,需要时再挂回。 */} {/* ⭐ 分配单时效 —— 只在单子**还挂在人手上**时显示(回到池子里的没有"还剩多久")。
⚠️ 基于 assignmentExpiresAt(已启用),不是 recycleAt(认领的 24h 兜底,从未启用)。 */}
{plan.assigneeUserId && <ExpiryBadge expiresAt={plan.assignmentExpiresAt} />}
{/* 用户名 + 头像 + 退出 —— 嵌入宿主时整体隐藏(见 IdentityCluster) */} {/* 用户名 + 头像 + 退出 —— 嵌入宿主时整体隐藏(见 IdentityCluster) */}
<IdentityCluster /> <IdentityCluster />
</div> </div>
...@@ -1311,40 +1313,60 @@ function AIDisclaimerFooter({ ...@@ -1311,40 +1313,60 @@ function AIDisclaimerFooter({
} }
// ────────────────────────────────────────── // ──────────────────────────────────────────
// RecycleCountdown — 实时倒计时 HH:MM:SS,基于 plan.recycleAt // ExpiryBadge — 分配单还剩多久(到点自动退回召回池)
// 暂无自动回收机制,头部已不挂载(见上方);组件保留以便日后恢复。
// ────────────────────────────────────────── // ──────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-unused-vars /**
function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) { * ⭐ 显示的是 **`assignmentExpiresAt`**(分配单时效),⛔ 不是 `recycleAt`。
*
* 两个机制,长期被混为一谈:
* · `recycleAt` 认领后 24h 兜底回收 —— 生产**从未启用**,值几乎恒为 null。
* 头部原来那个 `HH:MM:SS` 倒计时就是基于它的,所以一直挂着"未设置回收时间",
* 后来干脆注释掉了(见 git 历史里那句"暂无自动回收机制")。
* · `assignmentExpiresAt` 主管在确认单上定的时效(1-7 天)—— **已启用**,
* AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 `assignment_expired`。
*
* ⚠️ 粒度也跟着换了:原来是 `HH:MM:SS`(为 24h 设计的),而现在时效是**天**级 ——
* 3 天的单显示 `71:59:58` 没人读得出来。改成「还剩 2 天」/「还剩 5 小时」,
* 只有进入最后 1 小时才精确到分钟(那时候才真需要紧迫感)。
* ⚠️ 只在**单子还挂在人手上**时显示:回到池子里的单没有"还剩多久"可言。
*/
function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
useEffect(() => { useEffect(() => {
const i = setInterval(() => setTick((t) => t + 1), 1000); // 天级粒度不需要每秒跳;30 秒一次足够,也省得整页每秒重渲染
const i = setInterval(() => setTick((t) => t + 1), 30_000);
return () => clearInterval(i); return () => clearInterval(i);
}, []); }, []);
// 引用 tick 避免 lint warn(实际效果靠 setTick re-render)
void tick; void tick;
if (!recycleAt) { if (!expiresAt) return null;
return (
<span className="tabular-nums text-slate-400">未设置回收时间</span> const ms = expiresAt.getTime() - Date.now();
);
}
const ms = recycleAt.getTime() - Date.now();
if (ms <= 0) { if (ms <= 0) {
return ( return (
<span className="tabular-nums"> <span className="inline-flex items-center gap-1 rounded-md bg-rose-50 px-2 py-1 text-[11.5px] font-medium text-rose-600 ring-1 ring-inset ring-rose-200">
回收倒计时 <strong className="text-rose-600">已到期</strong> 已过期 · 即将退回池子
</span> </span>
); );
} }
const s = Math.floor(ms / 1000); const mins = Math.floor(ms / 60_000);
const hh = String(Math.floor(s / 3600)).padStart(2, '0'); const hours = Math.floor(mins / 60);
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0'); const days = Math.floor(hours / 24);
const ss = String(s % 60).padStart(2, '0'); const label =
days >= 1 ? `还剩 ${days} 天` : hours >= 1 ? `还剩 ${hours} 小时` : `还剩 ${mins} 分钟`;
// 最后一天开始转暖色,最后一小时转红 —— ⛔ 别更早变红:时效本来就是几天,
// 提前两天就飘红会让客服对颜色脱敏,真到期时反而看不见
const tone = const tone =
s < 1800 ? 'text-rose-600' : s < 3600 ? 'text-amber-600' : 'text-emerald-600'; hours < 1
? 'bg-rose-50 text-rose-600 ring-rose-200'
: days < 1
? 'bg-amber-50 text-amber-700 ring-amber-200'
: 'bg-slate-50 text-slate-500 ring-slate-200';
return ( return (
<span className="tabular-nums"> <span
回收倒计时 <strong className={tone}>{`${hh}:${mm}:${ss}`}</strong> title={`${expiresAt.toLocaleString('zh-CN')} 到期后自动退回召回池`}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11.5px] font-medium ring-1 ring-inset ${tone}`}
>
{label}退回
</span> </span>
); );
} }
......
...@@ -86,6 +86,8 @@ export type PlanDetailData = { ...@@ -86,6 +86,8 @@ export type PlanDetailData = {
assigneeName?: string | null; assigneeName?: string | null;
assignedAt: string | null; assignedAt: string | null;
recycleAt: string | null; recycleAt: string | null;
/// ⭐ 分配单到期时刻(客服手上这单还剩多久);⚠️ 与 recycleAt 是两个机制,见 @pac/types 的注释
assignmentExpiresAt: string | null;
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。 /// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
/// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标 /// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: string | null; snoozedUntil: string | null;
......
...@@ -45,7 +45,21 @@ export const FollowupPlanSchema = z.object({ ...@@ -45,7 +45,21 @@ export const FollowupPlanSchema = z.object({
recommendedChannel: z.string().nullable(), recommendedChannel: z.string().nullable(),
evidence: PlanEvidenceSchema, evidence: PlanEvidenceSchema,
status: PlanStatusSchema, status: PlanStatusSchema,
/**
* 认领的 24h 兜底回收时刻。⚠️ 生产**未启用**(PAC_PLAN_AUTO_RECYCLE 默认 off),
* 绝大多数时候是 null —— ⛔ 别拿它当"这单什么时候到期"。
*/
recycleAt: z.string().nullable(), recycleAt: z.string().nullable(),
/**
* ⭐ **分配单的到期时刻** —— 这才是"这单什么时候自动退回池子"。
*
* 与 `recycleAt` 是**两个机制**,别混:
* · recycleAt 认领后 24h 兜底,**未启用**;
* · assignmentExpiresAt 主管在确认单上定的时效(1-7 天),**已启用**
* (AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 assignment_expired)。
* 客服要看的是后者:它决定他手上这单还剩多久。
*/
assignmentExpiresAt: z.string().nullable(),
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算); /// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算);
/// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制 /// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制
snoozedUntil: z.string().nullable(), snoozedUntil: z.string().nullable(),
......
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