Commit fa0b286b by luoqi

refactor(web): 分配按钮内置 loading,取到数再开矩阵浮层

原来是「点分配 → 立刻弹浮层 → 浮层里转圈 → 数据到了重排」。
改成 shadcn 的常规做法:**加载态落在触发钮上**(钮里转圈 + 禁用),
取到数才把浮层打开,于是浮层一出现就是完整的矩阵,不会先以骨架尺寸出现再重排
—— 浮层的抖动比列表刺眼得多。

实现上 Popover 改**受控**:Radix 请求"打开"时先不开,去取数,取回来再置 open。
️ 关闭要照常放行(点外面 / 再点一次钮),否则浮层关不掉。

PoolMatrix 随之变成**纯展示组件**:数据由调用方传入,不再自取,
loading / error 两段 UI 一并删掉 —— 取数失败只弹 toast **不开浮层**,
开一个空浮层比不开更让人困惑。

️ 每次点都重取,不缓存:池子随时在变(客服在处理、引擎在重算),
缓存一份旧的会让主管照着过期的数字挑格子,而他完全看不出这数是旧的。
一次 ~90ms,不值得为它引入失效逻辑。

触发钮换成 ui/button 的 <Button>(尺寸压到 h-7 贴合 tab 行):
拿到 shadcn 的 gap-2 / disabled:opacity-50 / focus ring,不再手写一套。

next build 通过,971 tests passing。(按要求未在浏览器复验)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 08a4b565
......@@ -27,6 +27,8 @@ 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 { Button } from '@/components/ui/button';
import type { PoolMatrix as PoolMatrixData } from './plans-api';
/**
* PatientPickerRail — 详情页固定左栏(第 4 列):一页式工作台的"选患者"列。
......@@ -87,6 +89,9 @@ export function PatientPickerRail({
* 「点进去列表 44 条」天然一致(口径对数,见后端 plan.service.buildListWhere 的注释)。
*/
const [matrixMode, setMatrixMode] = useState(false);
/// 矩阵数据 + 取数中。⭐ 数据**先取好再开浮层**,所以 loading 落在「分配」钮上而不是浮层里
const [matrix, setMatrix] = useState<PoolMatrixData | null>(null);
const [matrixLoading, setMatrixLoading] = useState(false);
const [cell, setCell] = useState<{ treatment: string; treatmentZh: string; temperature: 'hot' | 'warm' | 'cold' } | null>(null);
const user = useAuthStore((st) => st.user);
......@@ -175,6 +180,27 @@ export function PatientPickerRail({
// 要复活的话是加回一个按钮 + 一次 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);
}
};
/**
* 点矩阵格子 = 完成「初选」,人群随即交给助手(生产线 ① → ②)。
*
* ⚠️ 三件事一起做,少一件都会出问题:
......@@ -260,19 +286,32 @@ export function PatientPickerRail({
关掉浮层就回到他原来的位置,不会丢滚动位置和筛选(T16:不新开路由、不换心智)。
⚠️ `<lg` 不提供 —— 8×3 在 375px 抽屉里做不出可读密度,主管基本桌面办公。 */}
{canDispatch && view === 'pool' && (
<Popover open={matrixMode} onOpenChange={setMatrixMode}>
<Popover
open={matrixMode}
// ⭐ 受控:Radix 请求"打开"时**先不开**,去取数,取回来再真正打开 ——
// 这样加载态就落在钮上(shadcn 常规做法),而不是先弹空浮层再在里面转圈。
// ⚠️ 关闭要照常放行(点外面 / 再点一次钮),否则浮层关不掉。
onOpenChange={(next) => {
if (!next) {
setMatrixMode(false);
return;
}
void openMatrix();
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="ml-auto hidden self-center rounded bg-brand-600 px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-brand-700 lg:block"
<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>
</Button>
</PopoverTrigger>
{/* 宽度交给 PoolMatrix 里的 Card 自己定(w-auto),浮层只负责定位与动效;
两处都写宽度必然对不齐,而且改一处会静默留下另一处的白边。 */}
<PopoverContent align="end" sideOffset={6} className="w-auto p-0">
<PoolMatrix selected={cell} onPick={pickCell} />
{/* matrix 一定非空:openMatrix 取到数才把 matrixMode 置 true */}
{matrix && <PoolMatrix data={matrix} selected={cell} onPick={pickCell} />}
</PopoverContent>
</Popover>
)}
......
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import {
TEMPERATURE_META,
TEMPERATURE_ORDER,
......@@ -11,7 +9,7 @@ import {
} from '@pac/types';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api';
import type { PoolMatrix as PoolMatrixData, PoolMatrixRow } from './plans-api';
/**
* PoolMatrix — 初选矩阵(8 潜在治疗 × 3 召回窗口)。排版按 claude design 的 **1b「连续色带」**。
......@@ -57,11 +55,18 @@ const LABEL_W = 'w-[54px]';
const ROW_H = 'h-[30px]';
export function PoolMatrix({
clinicId,
data,
selected,
onPick,
}: {
clinicId?: string;
/**
* 矩阵数据 —— **由调用方取好再传进来**,本组件不自己发请求。
*
* ⭐ 这样「加载中」才能落在**触发按钮**上(shadcn 常规做法:钮里转圈 + 禁用),
* 而不是先弹一个空浮层再在里面转圈 —— 后者浮层会先以骨架尺寸出现、
* 数据到了再重排,浮层的抖动比列表刺眼得多。
*/
data: PoolMatrixData;
/** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null;
/**
......@@ -77,34 +82,6 @@ export function PoolMatrix({
rect: { x: number; y: number; w: number; h: number };
}) => void;
}) {
const [data, setData] = useState<PoolMatrix | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setError(null);
plansApi
.matrix(clinicId)
.then((d) => alive && setData(d))
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, [clinicId]);
if (error) {
return <div className="p-4 text-xs text-rose-600">矩阵加载失败:{error}</div>;
}
if (!data) {
// 骨架与真身**同高**,浮层不会先窄后宽跳一下(浮层的抖动比列表更刺眼)
return (
<div className="flex h-[318px] items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在数人…
</div>
);
}
const showUnknown = data.unknownTotal > 0;
/// 列汇总 = 该列 8 行之和。⚠️ 现算不请求:后端给的是格子,汇总是纯展示派生,不该多一个字段去漂
const colTotal = (c: TempKey) => data.rows.reduce((a, r) => a + r[c], 0);
......
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