Commit a50ad3ff by luoqi

feat(执行页): 撤掉「召回池」tab 与「分配」按钮 —— 分配已经搬去主管工作台

产品:主管进客服执行页时,召回池和分配隐藏。

T16 原版把池子和分配挂在这一页,是因为那时主管和客服共用它。
08-04 评审推翻了后半句、分配搬进独立的主管工作台之后,
这一页就该回到它唯一的职责:**把手上的任务打完**。留着就是同一个动作两个入口。

️ 藏 tab 不等于做到了 —— view 还有两条会自己滑到 pool 的路,一起堵掉:
  ① 左栏「我的为空 → 自动回落召回池」的 effect
  ② /plans 落地页「② 召回池第一个(仅主管)」的兜底
     —— 它是最后一条还能把人滑进池子的路:主管的「我的」一空就被送进
     池子里某个患者的详情页,而左栏根本没有那个 tab,他会以为这条单是分给自己的。

顺带清掉随之而来的死代码:矩阵浮层(openMatrix / pickCell / matrix 三个 state)、
canDispatch 判据、以及 PoolMatrix / Button / useAssistantStore 等 6 个 import。
️ view='pool'/'all' 的**取数逻辑保留**,将来要放回 tab 时不用重接。

空态文案跟着改:原来主管那句写着「我的任务与召回池都是空的,可以在左侧调整筛选(视图/…)」
—— 那两样东西现在都没有了,照说就是让人去找一个不存在的地方。
改成「这一页只显示分给你自己的任务。要给团队分新的一批,去主管工作台」+ 一个入口。

实测:主管 / 客服进执行页,左栏都只剩「我的」,无分配按钮;
主管空态给出「主管工作台 」,且不再被自动滑进召回池。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 586828ca
Pipeline #3566 failed in 0 seconds
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Inbox, RefreshCw } from 'lucide-react';
import { ArrowUpRight, Inbox, RefreshCw } from 'lucide-react';
import { Permission } from '@pac/types';
import { Can } from '@/components/can';
import { useHasPermission } from '@/hooks/use-permission';
......@@ -16,8 +17,9 @@ import { isEmbedded } from '@/lib/embed';
*
* 进来直接落到详情工作台,选人规则(纯规则):
* ① 我的「进行中」第一个(优先续上手头工作)
* ② 没有 → 召回池优先级最高的一个(开新工作)
* ③ 都没有 → 空工作台:左栏照常(可自行调筛选找人)+ 右侧整体空态
* ② 没有 → 空工作台
* ⛔ 没有"② 召回池第一个"那一档了(2026-08-10):池子与分配整个搬去了主管工作台,
* 这一页只回答「分给我的还有哪些」。
* 所有旧入口(/ 重定向、各处"返回召回池"链接)自动适配 —— 都会被本页弹回详情。
*/
export default function PlansEntryPage() {
......@@ -30,7 +32,7 @@ export default function PlansEntryPage() {
function EntryResolver() {
const router = useRouter();
/// 主管判据(T16):只有他有召回池,客服的落地兜底不能滑进池子
/// 主管判据:决定**落地默认**(工作台 vs 执行页)与空态文案,⛔ 不再决定"能不能看池子"
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving');
......@@ -76,18 +78,15 @@ function EntryResolver() {
router.replace(`/plans/${mine.items[0].id}`);
return;
}
// ② 召回池第一个 —— ⭐ **仅主管**。
// 客服没有召回池入口(T16),这里若不门控就是一条隐式的认领通道:
// 他的「我的」一空,系统就自动把他送进池子里某个患者的详情页,
// 而那个人根本不是分给他的。tab 藏了没用,路径也得堵。
if (canDispatch) {
const pool = await plansApi.list({ view: 'pool', sort: 'priority_desc', page: 1, pageSize: 1 });
if (cancelled) return;
if (pool.items[0]) {
router.replace(`/plans/${pool.items[0].id}`);
return;
}
}
/**
* 🔴 这里**曾经有一个「② 召回池第一个(仅主管)」的兜底**,2026-08-10 撤掉。
*
* 执行页已经没有召回池了(产品定:池子与分配整个搬去主管工作台),
* 留着这条兜底就是**唯一一条还能把人滑进池子的路** ——
* 主管的「我的」一空,系统就自动把他送进池子里某个患者的详情页,
* 而左栏根本没有那个 tab,他会以为这条单是分给自己的。
* ⛔ 别加回来:要看池子去工作台的「分一批新的」。
*/
setPhase('empty');
} catch {
if (!cancelled) setPhase('empty');
......@@ -129,15 +128,29 @@ function EntryResolver() {
<Inbox className="h-7 w-7" />
</span>
<div className="text-[15px] font-medium text-slate-700">
{canDispatch ? '当前没有待跟进的召回任务' : '暂无分配给你的任务'}
{canDispatch ? '你名下没有待跟进的任务' : '暂无分配给你的任务'}
</div>
{/* ⭐ 客服看到的空态不能提"召回池"——他没有那个入口,提了只会让他去找一个不存在的地方。
主管的空态照旧(他确实两边都能看)。 */}
{/*
⭐ 空态**不许再提「召回池」和「左侧切视图」**(2026-08-10):
执行页已经没有那两样东西了 —— 提了就是让人去找一个不存在的地方。
⚠️ 主管与客服的差别只剩**下一步去哪** :主管去工作台自己分一批,
客服等派发。⛔ 别再按角色说两套"能看到什么"。
*/}
<p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500">
{canDispatch
? '我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,或等召回引擎下次重算后刷新。'
? '这一页只显示分给你自己的任务。要给团队分新的一批,去主管工作台。'
: '手头的任务都处理完了。新任务由主管统一派发,稍后刷新看看。'}
</p>
{/* 主管的空态给一条明确的去路 —— ⛔ 别只说"去工作台"却不给入口 */}
{canDispatch && (
<Link
href="/supervisor"
className="inline-flex items-center gap-1 text-[12.5px] text-brand-700 hover:underline"
>
主管工作台
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
)}
<button
type="button"
onClick={() => window.location.reload()}
......
......@@ -16,8 +16,6 @@ import {
type ReleaseReason,
personaTagDimId,
potentialTreatmentCardLabel,
TEMPERATURE_META,
type TemperatureValue,
} from '@pac/types';
import { cn, formatGender } from '@/lib/utils';
import { actionTemplate } from '@/lib/action-url';
......@@ -26,12 +24,8 @@ import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { usePatientPicker, type PickerFilters } from './use-patient-picker';
import { PoolMatrix } from './pool-matrix';
import { ReleaseReasonDialog } from './release-reason-dialog';
import { Button } from '@/components/ui/button';
import type { PoolMatrix as PoolMatrixData } from './plans-api';
/**
* PatientPickerRail — 详情页固定左栏(第 4 列):一页式工作台的"选患者"列。
......@@ -44,18 +38,22 @@ import type { PoolMatrix as PoolMatrixData } from './plans-api';
type View = PickerFilters['view'];
/**
* '全部' tab 已下线(信息过载;召回池 + 我的 足够日常)。view='all' 逻辑保留备用。
* '全部' tab 已下线(信息过载)。view='all' / 'pool' 的取数逻辑保留备用。
*
* ⭐ 2026-08(T16):**召回池只对主管可见**。客服看到的只有「我的」——
* 任务由主管派发,他不需要、也不应该自助去池子里捞人。
* ⚠️ 藏 tab **不等于**做到了 T16:view 还有两条会自己滑到 pool 的路
* (下方的自动回落 effect、以及 /plans 落地页的兜底),两条都必须一起门控,
* 否则 tab 看不见但列表里躺着的还是整池 —— 而且不会有任何报错。
* 🔴 2026-08-10:**执行页只剩「我的」,召回池与「分配」整个撤掉**(产品定)。
*
* ── 演进 ────────────────────────────────────────────────────────
* 2026-08(T16 原版):召回池只对主管可见 —— 那时主管和客服共用这一页,
* 池子和分配只能挂在这儿。08-04 评审推翻了后半句,分配搬进了独立的主管工作台,
* 这一页于是回到它唯一的职责:**把手上的任务打完**。
* ⛔ 别再把池子/分配加回来:同一个动作两个入口,状态同步和归属口径都会跟着变复杂,
* 而工作台那边的矩阵本来就是池子的视图(「分一批新的」)。
*
* ⚠️ 藏 tab **不等于**做到了 —— view 还有两条会自己滑到 pool 的路
* (下方的自动回落 effect、以及 /plans 落地页的兜底),必须一起堵,
* 否则 tab 看不见但列表里躺着的还是整池,而且不会有任何报错。这两处都已改。
*/
const VIEW_TABS: Array<{ v: View; label: string; requires?: Permission }> = [
{ v: 'pool', label: '召回池', requires: Permission.PLAN_DISPATCH },
{ v: 'mine', label: '我的' },
];
const VIEW_TABS: Array<{ v: View; label: string }> = [{ v: 'mine', label: '我的' }];
/**
* 左栏那一排筛选的可见性 —— 两个入口**分开控**(2026-08-04 产品走查先一起藏,
......@@ -84,15 +82,11 @@ export function PatientPickerRail({
onNavigated?: () => void;
}) {
const router = useRouter();
/// ⭐ 主管判据。⛔ 别用 PLAN_VIEW_ALL / PLAN_ASSIGN —— 前者是"看全部经办人"、
/// 后者连 staff 都有(那是自助认领语义),两个都区分不出主管。
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const canRecycle = useHasPermission(Permission.PLAN_RECYCLE); // 「退回」按钮门控(staff 也有,只能退自己的)
// 默认进「我的」;加载后若「我的」为空,自动回落「召回池」(见下方 effect)。
// 与落地页选默认患者同策略(mine 优先,空则 pool)
// ⭐ 恒为 'mine' —— 执行页只剩这一个 view(见 VIEW_TABS)。
// ⚠️ setView 仍留着:'pool'/'all' 的取数逻辑没删,将来要放回 tab 时不用重接
const [view, setView] = useState<View>('mine');
const autoFellBackRef = useRef(false); // 只自动回落一次,之后尊重用户手动切换
const [search, setSearch] = useState('');
const [keyword, setKeyword] = useState(''); // 防抖后的值(真正上服务端)
const composingRef = useRef(false); // 中文输入法组字中(true 时别拿拼音中间态"李石ming"去搜)
......@@ -105,22 +99,6 @@ export function PatientPickerRail({
const [clinics, setClinics] = useState<string[]>([]);
const [tags, setTags] = useState<Set<string>>(new Set()); // "key:value"
/**
* ⭐ 矩阵是召回池的一个**视图模式**,不是新路由(T16:主管本质也是客服,不能劈成两个身份)。
*
* ⛔ 初选**不动左栏列表**(2026-08 产品走查)。点一格是把这批人**交给助手**,
* 人群的证据在助手的确认单里(那才是要审的东西);顺手把左栏也筛掉,
* 等于在助手之外又开了第二个"这批人是谁"的说法 —— 两处不一致时主管信哪个?
* 而且左栏是他**挑患者**的地方,被上一次初选筛住会让他以为池子空了。
*/
const [matrixMode, setMatrixMode] = useState(false);
/// 矩阵数据 + 取数中。⭐ 数据**先取好再开浮层**,所以 loading 落在「分配」钮上而不是浮层里
const [matrix, setMatrix] = useState<PoolMatrixData | null>(null);
const [matrixLoading, setMatrixLoading] = useState(false);
/// 上一次点过的那一格 —— **只用于矩阵内部回显**(再打开时能看出刚交过哪一格),不外溢到列表
// ⚠️ 用共享的 TemperatureValue,⛔ 别手写联合 —— 档位从 3 扩到 6 时手写的那份不会跟着变,
// 而它只在**赋值处**报错,读的地方(如 TEMPERATURE_META[t])会静默漏档。
const [cell, setCell] = useState<{ treatment: string; temperature: TemperatureValue } | null>(null);
/**
* 待确认退回的那一条 —— ⭐ **退回必须先选原因**(T7),⛔ 不能点一下就走。
* 原因分布是主管调整分配策略的输入;拿不到它,他只知道"退了 12 条",不知道该改什么。
*/
......@@ -166,25 +144,14 @@ export function PatientPickerRail({
);
const { items, total, loading, hasMore, error, loadMore, patchItem, removeItem } = usePatientPicker(filters);
// 默认「我的」加载完成后若为空 → 自动回落「召回池」(只一次)。
// 与落地页 mine 优先、空则 pool 的默认患者策略一致,避免左栏 tab 与所选患者来源不符。
// ⚠️ 必须等「一次真实加载完成」(loading true→false)再判空:usePatientPicker 的 loading
// 初值是 false、total 初值 0,若不卡这个转变,首帧就会误判空而抢切 pool(且并发守卫会
// 跳过 pool 的 fetch → 出现 tab=召回池 但数据仍是我的 的错位)。
const prevLoadingRef = useRef(false);
useEffect(() => {
const justFinished = prevLoadingRef.current && !loading; // 刚从加载中变为完成
prevLoadingRef.current = loading;
if (autoFellBackRef.current) return;
// 🔴 T16 最容易漏的一处:客服的「我的」为空时,原逻辑会把他**直接扔进召回池** ——
// tab 已经藏了,但 view 变成了 pool,列表里躺着整池患者,而且不报任何错。
// 藏入口和堵路径是两件事,这里是后者。
if (!canDispatch) return;
if (view === 'mine' && justFinished && total === 0) {
autoFellBackRef.current = true;
setView('pool');
}
}, [view, loading, total, canDispatch]);
/**
* 🔴 这里**曾经有一个「我的为空 → 自动回落召回池」**,2026-08-10 随召回池一起撤。
*
* 它是"藏入口"之外必须一起堵的那条路:tab 藏了、view 却被这段代码切成 pool,
* 列表里躺着整池患者,而且不报任何错。现在执行页压根没有 pool 这个去处 ——
* 「我的」空了就老老实实显示空态(任务由主管派发,自己不该去池子里捞人)。
* ⛔ 别再加回来。
*/
// 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)。
// 终态(completed/abandoned)行已不属于 pool/mine 视图(服务端过滤口径)→ 原地剔除,
......@@ -207,60 +174,13 @@ export function PatientPickerRail({
// ⭐ T16:认领入口已从前端全部撤掉(后端 POST :id/assign 保留,见 plans-api.recycle 旁注)。
// 客服的单由主管派发;将来若放开客服主动性,是把入口加回来的事,不用改模型。
// ⛔ 「按当前筛选的人群移交助手」这条路已撤(2026-08 产品走查):入口只留矩阵一个。
// 生产线 ① 明写「主管在矩阵上点一格」—— 两个入口做同一件事,主管得先想用哪个。
// 要复活的话是加回一个按钮 + 一次 emitPetEvent/ask 的事,模型和后端都不用动。
/**
* 开矩阵 —— **先取数,取到了才开浮层**。
*
* ⚠️ 每次都重取,不缓存:池子随时在变(客服在处理、引擎在重算),
* 缓存一份旧的会让主管照着过期的数字挑格子,而他完全看不出这数是旧的。
* 一次 ~90ms,不值得为它引入失效逻辑。
* ⚠️ 取数失败**不开浮层**,只弹一条 toast —— 开一个空浮层比不开更让人困惑。
*/
const openMatrix = async () => {
setMatrixLoading(true);
try {
const d = await plansApi.matrix();
setMatrix(d);
setMatrixMode(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : '矩阵加载失败');
} finally {
setMatrixLoading(false);
}
};
/**
* 点矩阵格子 = 完成「初选」,人群随即交给助手(生产线 ① → ②)。
* 🔴 这里**曾经有 `openMatrix` / `pickCell` 两段**(取池子矩阵 → 浮层 → 点一格交给助手),
* 2026-08-10 随「分配」按钮一起撤。
*
* ⛔ **不碰左栏列表** —— 这一格只往助手那边走。左栏保持原样(原来的筛选、滚动位置都在),
* 主管的注意力跟着数据流粒子过去,回头看的是确认单,不是被筛过的列表。
* ⚠️ 关掉浮层是必须的:否则确认单在右边开出来了,他还盯着矩阵。
* ⚠️ 这里**不传 planId 列表** —— 「怎么圈人」归后端,传了等于把收敛规则搬到前端,而那是会漂的;
* 「怎么演」归 pet-brain / 舞台层,业务侧只发语义事件。
* 那条生产线整体搬去了主管工作台的「分一批新的」——⛔ 别在这一页复活它:
* 同一个动作两个入口,主管得先想用哪个,状态同步和归属口径也跟着复杂。
*/
const pickCell = (c: {
treatment: string;
treatmentZh: string;
temperature: TemperatureValue;
count: number;
rect: { x: number; y: number; w: number; h: number };
}) => {
const tempZh = TEMPERATURE_META[c.temperature].zh;
setCell({ treatment: c.treatment, temperature: c.temperature });
setMatrixMode(false);
// ⭐ 一次调用做完整段移交:发数据流 → 粒子飞到助手 → 落地后自动开大窗并说那句话。
// 这里**不自己 setTimeout**:动画多长是舞台层的事,业务侧知道了就得跟着改(见 store 注释)。
useAssistantStore.getState().handoff({
text: `帮我给「${c.treatmentZh} · ${tempZh}」这批患者出一份分配方案`,
from: c.rect,
count: c.count,
treatment: c.treatmentZh,
temperature: tempZh,
});
};
// 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。
// 返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。
......@@ -302,7 +222,7 @@ export function PatientPickerRail({
<aside className="flex h-full w-[320px] flex-none flex-col border-r border-slate-100 bg-white">
{/* view tabs */}
<div className="flex flex-none gap-1 border-b border-slate-100 px-2 pt-2">
{VIEW_TABS.filter((t) => !t.requires || canDispatch).map((t) => (
{VIEW_TABS.map((t) => (
<button
key={t.v}
type="button"
......@@ -317,45 +237,8 @@ export function PatientPickerRail({
{t.label}
</button>
))}
{/* ⭐ 「分配」是主管在这一页的**主行动**,所以给主色实心 —— 其余控件都是次要的。
矩阵**浮层展开**而不是替换左栏:主管挑格子时列表还在原处,
关掉浮层就回到他原来的位置,不会丢滚动位置和筛选(T16:不新开路由、不换心智)。
⚠️ `<lg` 不提供 —— 8×3 在 375px 抽屉里做不出可读密度,主管基本桌面办公。 */}
{canDispatch && view === 'pool' && (
<Popover
open={matrixMode}
// ⭐ 受控:Radix 请求"打开"时**先不开**,去取数,取回来再真正打开 ——
// 这样加载态就落在钮上(shadcn 常规做法),而不是先弹空浮层再在里面转圈。
// ⚠️ 关闭要照常放行(点外面 / 再点一次钮),否则浮层关不掉。
onOpenChange={(next) => {
if (!next) {
setMatrixMode(false);
return;
}
void openMatrix();
}}
>
<PopoverTrigger asChild>
<Button
size="sm"
disabled={matrixLoading}
className="ml-auto hidden h-7 px-2.5 text-[11.5px] lg:inline-flex"
>
{matrixLoading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
分配
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} className="w-auto p-0">
{/* matrix 一定非空:openMatrix 取到数才把 matrixMode 置 true */}
{matrix && <PoolMatrix data={matrix} selected={cell} onPick={pickCell} />}
</PopoverContent>
</Popover>
)}
<span
className={cn(
'self-center pr-1 text-[10.5px] tabular-nums text-slate-400',
canDispatch && view === 'pool' ? 'pl-2' : 'ml-auto',
)}
className="ml-auto self-center pr-1 text-[10.5px] tabular-nums text-slate-400"
>
{total}
</span>
......
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