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: {
assigneeUserId: string | null;
assignedAt: Date | null;
recycleAt: Date | null;
assignmentExpiresAt: Date | null;
snoozedUntil: Date | null;
recallFeedback: string | null;
recallFeedbackNote: string | null;
......@@ -614,6 +615,8 @@ function serializePlan(plan: {
assigneeUserId: plan.assigneeUserId,
assignedAt: plan.assignedAt?.toISOString() ?? null,
recycleAt: plan.recycleAt?.toISOString() ?? null,
/// ⭐ 分配单到期时刻(客服手上这单还剩多久);⚠️ 与 recycleAt 是两个机制,见 schema 注释
assignmentExpiresAt: plan.assignmentExpiresAt?.toISOString() ?? null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X"
snoozedUntil: plan.snoozedUntil?.toISOString() ?? null,
/// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null
......
......@@ -177,6 +177,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
assigneeUserId: real.plan?.assigneeUserId ?? null,
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
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,
recallFeedback: (real.plan?.recallFeedback ?? null) as 'up' | 'down' | null,
recallFeedbackNote: real.plan?.recallFeedbackNote ?? null,
......
......@@ -356,6 +356,8 @@ export const mockPlan = {
assigneeUserId: 'usr_csliu' as string | null,
assignedAt: NOW,
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"角标
snoozedUntil: null as Date | null,
/// 召回反馈(plan 级)— 标题栏拇指当前态 + 文字
......
......@@ -1241,7 +1241,9 @@ function TopBar({
)}
</div>
)}
{/* 回收倒计时已隐藏 —— 暂无自动回收机制(返池改为手动)。RecycleCountdown 组件保留,需要时再挂回。 */}
{/* ⭐ 分配单时效 —— 只在单子**还挂在人手上**时显示(回到池子里的没有"还剩多久")。
⚠️ 基于 assignmentExpiresAt(已启用),不是 recycleAt(认领的 24h 兜底,从未启用)。 */}
{plan.assigneeUserId && <ExpiryBadge expiresAt={plan.assignmentExpiresAt} />}
{/* 用户名 + 头像 + 退出 —— 嵌入宿主时整体隐藏(见 IdentityCluster) */}
<IdentityCluster />
</div>
......@@ -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);
useEffect(() => {
const i = setInterval(() => setTick((t) => t + 1), 1000);
// 天级粒度不需要每秒跳;30 秒一次足够,也省得整页每秒重渲染
const i = setInterval(() => setTick((t) => t + 1), 30_000);
return () => clearInterval(i);
}, []);
// 引用 tick 避免 lint warn(实际效果靠 setTick re-render)
void tick;
if (!recycleAt) {
return (
<span className="tabular-nums text-slate-400">未设置回收时间</span>
);
}
const ms = recycleAt.getTime() - Date.now();
if (!expiresAt) return null;
const ms = expiresAt.getTime() - Date.now();
if (ms <= 0) {
return (
<span className="tabular-nums">
回收倒计时 <strong className="text-rose-600">已到期</strong>
<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">
已过期 · 即将退回池子
</span>
);
}
const s = Math.floor(ms / 1000);
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
const mins = Math.floor(ms / 60_000);
const hours = Math.floor(mins / 60);
const days = Math.floor(hours / 24);
const label =
days >= 1 ? `还剩 ${days} 天` : hours >= 1 ? `还剩 ${hours} 小时` : `还剩 ${mins} 分钟`;
// 最后一天开始转暖色,最后一小时转红 —— ⛔ 别更早变红:时效本来就是几天,
// 提前两天就飘红会让客服对颜色脱敏,真到期时反而看不见
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 (
<span className="tabular-nums">
回收倒计时 <strong className={tone}>{`${hh}:${mm}:${ss}`}</strong>
<span
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>
);
}
......
......@@ -86,6 +86,8 @@ export type PlanDetailData = {
assigneeName?: string | null;
assignedAt: string | null;
recycleAt: string | null;
/// ⭐ 分配单到期时刻(客服手上这单还剩多久);⚠️ 与 recycleAt 是两个机制,见 @pac/types 的注释
assignmentExpiresAt: string | null;
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
/// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: string | null;
......
......@@ -45,7 +45,21 @@ export const FollowupPlanSchema = z.object({
recommendedChannel: z.string().nullable(),
evidence: PlanEvidenceSchema,
status: PlanStatusSchema,
/**
* 认领的 24h 兜底回收时刻。⚠️ 生产**未启用**(PAC_PLAN_AUTO_RECYCLE 默认 off),
* 绝大多数时候是 null —— ⛔ 别拿它当"这单什么时候到期"。
*/
recycleAt: z.string().nullable(),
/**
* ⭐ **分配单的到期时刻** —— 这才是"这单什么时候自动退回池子"。
*
* 与 `recycleAt` 是**两个机制**,别混:
* · recycleAt 认领后 24h 兜底,**未启用**;
* · assignmentExpiresAt 主管在确认单上定的时效(1-7 天),**已启用**
* (AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 assignment_expired)。
* 客服要看的是后者:它决定他手上这单还剩多久。
*/
assignmentExpiresAt: z.string().nullable(),
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算);
/// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制
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