Commit 5c8dcff4 by luoqi

feat(web): 首屏标签按业务定序 + 点标签直达画像详情对应那一条

三处按业务意见调整:

① 顺序由业务定,写死在 PERSONA_KEY_FEATURE_KEYS 的数组序:
   治疗史 → 潜在治疗 → 价值分群 → 生命周期 → 权益身份 → 折扣锚点 →
   获客渠道 → 禁忌标签 → 特别关注
   (原来跟抽屉共用 personaFeatureSortKey,那是按"跟进要点/价值与阶段/基础属性"
    分组排的;首屏没有分组标题,照那个序排客服看不出章法)

② 急迫等级下首屏 —— 顶栏「优先级」条已经表达同一件事(急迫是它权重最大的一项),
   同屏两处讲一件事会被当成两个指标。

③ 去 hover,改点击:chip 变真 button,点了开画像详情抽屉并**滚到那张卡、加重描边**。
   扫首屏时鼠标划过就弹浮层是干扰;真想知道"这标签怎么算的"的人,该看的是完整那张卡。
   抽屉里十几张卡,要找的常在折叠线以下 —— 不定位的话点标签和点「详情 →」没区别。
   走「详情 →」进仍是看全量,不定位。

滚动等一帧再执行:抽屉是本次渲染才挂上的,同步滚时容器还没布局,scrollIntoView 是空操作。
label 视觉上去掉了,button 补 aria-label(读屏仍念得出标签名)。

657 tests / 42 suites 通过;web typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 3c3c4cf0
...@@ -18,6 +18,7 @@ export function Drawer({ ...@@ -18,6 +18,7 @@ export function Drawer({
open, open,
onClose, onClose,
kind, kind,
personaFocusKey,
chains, chains,
persona, persona,
summaries, summaries,
...@@ -33,6 +34,8 @@ export function Drawer({ ...@@ -33,6 +34,8 @@ export function Drawer({
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
kind: DrawerKind; kind: DrawerKind;
/** kind='persona' 时:打开后滚到这个标签并高亮(点身份卡首屏 chip 进来的)。null=看全量 */
personaFocusKey?: string | null;
chains: Chain[]; chains: Chain[];
persona: { computedAt: Date; refreshedAt: Date; features: PersonaFeature[] }; persona: { computedAt: Date; refreshedAt: Date; features: PersonaFeature[] };
summaries: { summaries: {
...@@ -117,7 +120,7 @@ export function Drawer({ ...@@ -117,7 +120,7 @@ export function Drawer({
// 「更新于」用 refreshedAt(内容真实新鲜度):只有天数漂移时后端就地刷新、不升版本, // 「更新于」用 refreshedAt(内容真实新鲜度):只有天数漂移时后端就地刷新、不升版本,
// computedAt 会停在版本创建那刻,拿它显示会让客服以为数据比实际更旧。 // computedAt 会停在版本创建那刻,拿它显示会让客服以为数据比实际更旧。
subtitle = `更新于 ${fmtRel(persona.refreshedAt)} · ${persona.features.length} 项画像`; subtitle = `更新于 ${fmtRel(persona.refreshedAt)} · ${persona.features.length} 项画像`;
body = <PersonaDetailList features={persona.features} facts={facts} />; body = <PersonaDetailList features={persona.features} facts={facts} focusKey={personaFocusKey} />;
} }
return ( return (
......
...@@ -51,12 +51,29 @@ function staleAnchorHint(feature: PersonaFeature): string | null { ...@@ -51,12 +51,29 @@ function staleAnchorHint(feature: PersonaFeature): string | null {
export function PersonaDetailList({ export function PersonaDetailList({
features, features,
facts, facts,
focusKey,
}: { }: {
features: PersonaFeature[]; features: PersonaFeature[];
facts: AdaptedFact[]; facts: AdaptedFact[];
/**
* 打开时要定位到的标签(身份卡首屏 chip 点进来带的)。
* 抽屉里十几张卡、要找的那张常在折叠线以下 —— 不滚过去等于让人自己翻,
* 那点开标签跟点「详情 →」就没区别了。
*/
focusKey?: string | null;
}) { }) {
const factById = React.useMemo(() => new Map(facts.map((f) => [f.id, f])), [facts]); const factById = React.useMemo(() => new Map(facts.map((f) => [f.id, f])), [facts]);
// 滚到目标卡。⚠️ 必须等一帧:抽屉是本次渲染才挂上的,同步滚时容器还没有布局,scrollIntoView 是空操作。
const focusRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!focusKey) return;
const id = requestAnimationFrame(() =>
focusRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }),
);
return () => cancelAnimationFrame(id);
}, [focusKey]);
const grouped = React.useMemo(() => { const grouped = React.useMemo(() => {
const sorted = [...features].sort((a, b) => { const sorted = [...features].sort((a, b) => {
const [ga, oa] = personaFeatureSortKey(a.key); const [ga, oa] = personaFeatureSortKey(a.key);
...@@ -90,7 +107,13 @@ export function PersonaDetailList({ ...@@ -90,7 +107,13 @@ export function PersonaDetailList({
<span className="ml-1.5 font-normal text-slate-300">{items.length}</span> <span className="ml-1.5 font-normal text-slate-300">{items.length}</span>
</h3> </h3>
{items.map((f) => ( {items.map((f) => (
<FeatureCard key={f.key} feature={f} factById={factById} /> <FeatureCard
key={f.key}
feature={f}
factById={factById}
focused={f.key === focusKey}
cardRef={f.key === focusKey ? focusRef : undefined}
/>
))} ))}
</section> </section>
))} ))}
...@@ -101,9 +124,14 @@ export function PersonaDetailList({ ...@@ -101,9 +124,14 @@ export function PersonaDetailList({
function FeatureCard({ function FeatureCard({
feature: f, feature: f,
factById, factById,
focused = false,
cardRef,
}: { }: {
feature: PersonaFeature; feature: PersonaFeature;
factById: Map<string, AdaptedFact>; factById: Map<string, AdaptedFact>;
/** 从首屏 chip 点进来的那张 —— 描边加重,滚到中间后一眼能认出是哪张 */
focused?: boolean;
cardRef?: React.RefObject<HTMLDivElement | null>;
}) { }) {
const [showAll, setShowAll] = React.useState(false); const [showAll, setShowAll] = React.useState(false);
const T = tone(f.tone); const T = tone(f.tone);
...@@ -123,7 +151,13 @@ function FeatureCard({ ...@@ -123,7 +151,13 @@ function FeatureCard({
const shown = showAll ? resolved : resolved.slice(0, MAX_EVIDENCE_ROWS); const shown = showAll ? resolved : resolved.slice(0, MAX_EVIDENCE_ROWS);
return ( return (
<div className="relative rounded-md border border-slate-100 p-3 pr-9"> <div
ref={cardRef}
className={cn(
'relative rounded-md border p-3 pr-9',
focused ? 'border-teal-300 bg-teal-50/40 ring-1 ring-teal-200' : 'border-slate-100',
)}
>
{/* 右上角 ? —— 真 button:可 Tab 聚焦,focus 也能唤出说明卡(触屏/键盘可达) */} {/* 右上角 ? —— 真 button:可 Tab 聚焦,focus 也能唤出说明卡(触屏/键盘可达) */}
<PersonaFeatureHover featureKey={f.key} value={f.value}> <PersonaFeatureHover featureKey={f.key} value={f.value}>
<button <button
......
...@@ -46,6 +46,7 @@ import { ...@@ -46,6 +46,7 @@ import {
ABANDON_REASON_META, ABANDON_REASON_META,
personaFeatureSortKey, personaFeatureSortKey,
isKeyPersonaFeature, isKeyPersonaFeature,
personaKeyFeatureOrder,
type AbandonReason, type AbandonReason,
type ExecutionOutcome, type ExecutionOutcome,
} from '@pac/types'; } from '@pac/types';
...@@ -160,6 +161,8 @@ export function PlanDetailApp({ ...@@ -160,6 +161,8 @@ export function PlanDetailApp({
const recallHistory = data.recallHistory ?? []; const recallHistory = data.recallHistory ?? [];
const returnVisits = data.returnVisits ?? []; const returnVisits = data.returnVisits ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null); const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
// 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null
const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 qwen3.7-max(极快 · 简洁) // 话术生成模型(具体型号);默认 qwen3.7-max(极快 · 简洁)
const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.7-max'); const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.7-max');
...@@ -528,6 +531,10 @@ export function PlanDetailApp({ ...@@ -528,6 +531,10 @@ export function PlanDetailApp({
<IdentityCard <IdentityCard
patient={patient} patient={patient}
features={persona.features} features={persona.features}
onOpenPersonaFeature={(key) => {
setPersonaFocusKey(key);
setDrawerOpen('persona');
}}
onOpenImage={() => onOpenImage={() =>
showToast('slate', '影像调阅', '跳转宿主页面') showToast('slate', '影像调阅', '跳转宿主页面')
} }
...@@ -538,7 +545,10 @@ export function PlanDetailApp({ ...@@ -538,7 +545,10 @@ export function PlanDetailApp({
<PersonaTagsCard <PersonaTagsCard
planId={plan.id} planId={plan.id}
features={persona.features} features={persona.features}
onOpenDetail={() => setDrawerOpen('persona')} onOpenDetail={() => {
setPersonaFocusKey(null); // 走「详情 →」是看全量,不定位到某一条
setDrawerOpen('persona');
}}
/> />
<KeyFactsCard <KeyFactsCard
patient={patient} patient={patient}
...@@ -723,8 +733,12 @@ export function PlanDetailApp({ ...@@ -723,8 +733,12 @@ export function PlanDetailApp({
<Drawer <Drawer
open={!!drawerOpen} open={!!drawerOpen}
onClose={() => setDrawerOpen(null)} onClose={() => {
setDrawerOpen(null);
setPersonaFocusKey(null); // 定位高亮只属于这一次打开,关抽屉即清
}}
kind={drawerOpen} kind={drawerOpen}
personaFocusKey={personaFocusKey}
chains={chains} chains={chains}
persona={persona} persona={persona}
summaries={summaries} summaries={summaries}
...@@ -1287,26 +1301,25 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) { ...@@ -1287,26 +1301,25 @@ function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) {
function IdentityCard({ function IdentityCard({
patient, patient,
features, features,
onOpenPersonaFeature,
onOpenImage, onOpenImage,
onOpenProfile, onOpenProfile,
}: { }: {
patient: typeof mockPatient; patient: typeof mockPatient;
/** 全量画像标签;卡内只挑「关键标签」露出(PERSONA_KEY_FEATURE_KEYS),其余在画像详情抽屉 */ /** 全量画像标签;卡内只挑「关键标签」露出(PERSONA_KEY_FEATURE_KEYS),其余在画像详情抽屉 */
features: typeof mockPersona.features; features: typeof mockPersona.features;
/** 点关键标签 → 开画像详情抽屉并滚到那一条 */
onOpenPersonaFeature: (featureKey: string) => void;
onOpenImage: () => void; onOpenImage: () => void;
onOpenProfile: () => void; onOpenProfile: () => void;
}) { }) {
// 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。 // 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。
// 选哪些是业务口径(PERSONA_KEY_FEATURE_KEYS),排序沿用抽屉那套(跟进要点 → 价值与阶段)。 // 选哪些 + 什么顺序都是业务口径,一并收在 PERSONA_KEY_FEATURE_KEYS(数组序即展示序)。
const keyFeatures = useMemo( const keyFeatures = useMemo(
() => () =>
features features
.filter((f) => isKeyPersonaFeature(f.key)) .filter((f) => isKeyPersonaFeature(f.key))
.sort((a, b) => { .sort((a, b) => personaKeyFeatureOrder(a.key) - personaKeyFeatureOrder(b.key)),
const [ga, oa] = personaFeatureSortKey(a.key);
const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key);
}),
[features], [features],
); );
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
...@@ -1367,10 +1380,10 @@ function IdentityCard({ ...@@ -1367,10 +1380,10 @@ function IdentityCard({
</div> </div>
{/* ⭐ 关键标签 —— 紧贴姓名下方(业务 2026-07-29:可快速查看,要细看再进画像详情)。 {/* ⭐ 关键标签 —— 紧贴姓名下方(业务 2026-07-29:可快速查看,要细看再进画像详情)。
chip 与画像标签卡/抽屉同一套组件,hover 仍能看「怎么算的」,不另起一套渲染。 chip 与画像标签卡/抽屉同一套组件,hover 仍能看「怎么算的」,不另起一套渲染。
plain:只出取值、统一素色(见 PersonaTagCloud 注释)。 */} plain:只出取值、统一素色、点开抽屉定位(见 PersonaTagCloud 注释)。 */}
{keyFeatures.length > 0 && ( {keyFeatures.length > 0 && (
<div className="mt-1.5"> <div className="mt-1.5">
<PersonaTagCloud features={keyFeatures} plain /> <PersonaTagCloud features={keyFeatures} plain onSelect={onOpenPersonaFeature} />
</div> </div>
)} )}
{patient.tags.length > 0 && ( {patient.tags.length > 0 && (
...@@ -2276,16 +2289,21 @@ function computeRisks({ ...@@ -2276,16 +2289,21 @@ function computeRisks({
// 每个特征 = 一颗 chip(label + 短值),按 tone 着色;hover 展开该标签的算法说明 // 每个特征 = 一颗 chip(label + 短值),按 tone 着色;hover 展开该标签的算法说明
// (PersonaFeatureHover 读 feature.key)。空值特征不出 chip。 // (PersonaFeatureHover 读 feature.key)。空值特征不出 chip。
// //
// plain 模式(身份卡首屏):**去 label、去彩色**,只留取值,样式对齐左栏列表卡片的通用标签 // plain 模式(身份卡首屏):**去 label、去彩色、去 hover**,只留取值,样式对齐左栏列表卡片
// 理由:首屏十来颗标签各自一种颜色 = 满屏彩条,反而看不出哪个要紧;取值本身自解释 // 理由:首屏十来颗标签各自一种颜色 = 满屏彩条,反而看不出哪个要紧;取值本身自解释
// (「紧急」「种植禁忌」「储值会员」),label 是冗余的。要看标签名/口径 → hover 或进抽屉。 // (「种植禁忌」「储值会员」),label 是冗余的。
// hover 说明卡也去掉了 —— 客服扫首屏时鼠标划过就弹卡是干扰,改成**点开抽屉定位到那一条**
// (onSelect):想知道"这标签怎么算的"的人本来就该看完整那张卡,而不是一张浮层。
// ────────────────────────────────────────── // ──────────────────────────────────────────
function PersonaTagCloud({ function PersonaTagCloud({
features, features,
plain = false, plain = false,
onSelect,
}: { }: {
features: typeof mockPersona.features; features: typeof mockPersona.features;
plain?: boolean; plain?: boolean;
/** plain 专用:点 chip → 开画像详情抽屉并定位到该标签 */
onSelect?: (featureKey: string) => void;
}) { }) {
if (!features.length) { if (!features.length) {
return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>; return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>;
...@@ -2304,25 +2322,38 @@ function PersonaTagCloud({ ...@@ -2304,25 +2322,38 @@ function PersonaTagCloud({
const { text: short, more } = compactPersonaValue(f.value, f.data); const { text: short, more } = compactPersonaValue(f.value, f.data);
// plain 下取值就是全部内容 —— 取不到值(理论上不该有)兜底显示标签名,不出空 chip // plain 下取值就是全部内容 —— 取不到值(理论上不该有)兜底显示标签名,不出空 chip
const text = plain ? short || f.label : short; const text = plain ? short || f.label : short;
const inner = (
<>
{!plain && <span className={cn('flex-none text-[10px] font-semibold', T.text)}>{f.label}</span>}
{text && (plain || text !== f.label) && (
<span className={cn('truncate', !plain && 'text-[10.5px] text-slate-700')}>{text}</span>
)}
{more > 0 && (
<span className="flex-none text-[9.5px] font-medium text-slate-400 tabular-nums">
+{more}
</span>
)}
</>
);
// 首屏:真 button —— 可 Tab 聚焦、读屏能念出标签名(视觉上 label 已去掉,靠 aria-label 补)
if (plain) {
return (
<button
key={f.key}
type="button"
onClick={() => onSelect?.(f.key)}
aria-label={`${f.label}:${text} —— 查看画像详情`}
className="inline-flex max-w-full items-center gap-1 rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200 hover:bg-slate-50 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500"
>
{inner}
</button>
);
}
return ( return (
<PersonaFeatureHover key={f.key} featureKey={f.key} value={f.value}> <PersonaFeatureHover key={f.key} featureKey={f.key} value={f.value}>
<span <span className={cn('inline-flex max-w-full cursor-help items-center gap-1 rounded px-1.5 py-0.5 ring-1', T.bg, T.ring)}>
className={cn( {inner}
'inline-flex max-w-full cursor-help items-center gap-1',
plain
? 'rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200'
: cn('rounded px-1.5 py-0.5 ring-1', T.bg, T.ring),
)}
>
{!plain && <span className={cn('flex-none text-[10px] font-semibold', T.text)}>{f.label}</span>}
{text && (plain || text !== f.label) && (
<span className={cn('truncate', !plain && 'text-[10.5px] text-slate-700')}>{text}</span>
)}
{more > 0 && (
<span className="flex-none text-[9.5px] font-medium text-slate-400 tabular-nums">
+{more}
</span>
)}
</span> </span>
</PersonaFeatureHover> </PersonaFeatureHover>
); );
......
...@@ -37,41 +37,43 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va ...@@ -37,41 +37,43 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va
* 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 —— * 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 ——
* 这里取舍的是**首屏**,不是信息。 * 这里取舍的是**首屏**,不是信息。
* *
* 选这十个的理由,按客服打这通电话的顺序: * ⭐ **数组顺序 = 首屏展示顺序**(业务定序,2026-07-29),不走 personaFeatureSortKey ——
* contraindication 能不能治 —— 安全红线,漏看会出事 * 抽屉那套序是按"跟进要点 / 价值与阶段 / 基础属性"分组的,首屏没有分组标题,
* special_attention 能不能打 —— 免打扰 / 屡次爽约,决定要不要拨 * 照那个序排出来客服看不出章法。这里是业务自己排的一条线:
* urgency_level 多急 * 做过什么 → 还差什么 → 这人什么档位 / 走到哪一步 → 能怎么付 / 谈价底线 →
* potential_treatment 聊什么 * 怎么来的 → 最后两颗是风险(禁忌 / 特别关注)。
* treatment_history 做过什么 —— 开场认人、避免推已经做过的项目
* rfm / lifecycle_stage 这人值多少、走到哪一步
* entitlement_status 能怎么付 —— 商保直付 / 储值 / 医保,报价前就得知道
* discount_anchor 谈价底线 —— 历史给过的最深折扣
* acquisition_channel 怎么来的 —— 口碑 / 活动,影响开场怎么称呼这段关系
* *
* 没进来的六个:时间偏好 / 治疗敏感(通话**中**才用得上,不是扫一眼要的); * 急迫等级不进首屏:顶栏「优先级」条已经表达同一件事(急迫是它的最大权重项),
* 同屏两处讲一件事,客服会以为是两个指标。
*
* 没进来的:时间偏好 / 治疗敏感(通话**中**才用得上,不是扫一眼要的);
* 年龄段 / 性别(姓名行已经有性别年龄,重复占位);家庭构成 / 转介绍达人 * 年龄段 / 性别(姓名行已经有性别年龄,重复占位);家庭构成 / 转介绍达人
* (有价值但不改变这通电话怎么开场)。 * (有价值但不改变这通电话怎么开场)。
* *
* ⚠️ 展示顺序不在这里定 —— 渲染按 personaFeatureSortKey(与抽屉同一套序), * ⚠️ 想加 key 先自问:客服扫一眼真会拿它做决定吗?加了要顺手排进上面那条线里。
* 本常量只管「哪些进首屏」。想加 key 先自问:客服扫一眼真会拿它做决定吗?
*/ */
export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [ export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
'contraindication',
'special_attention',
'urgency_level',
'potential_treatment',
'treatment_history', 'treatment_history',
'potential_treatment',
'rfm', 'rfm',
'lifecycle_stage', 'lifecycle_stage',
'entitlement_status', 'entitlement_status',
'discount_anchor', 'discount_anchor',
'acquisition_channel', 'acquisition_channel',
'contraindication',
'special_attention',
]; ];
/** 该标签是否进身份卡首屏(未登记的一律不进 —— 新标签得显式选进来) */ /** 该标签是否进身份卡首屏(未登记的一律不进 —— 新标签得显式选进来) */
export const isKeyPersonaFeature = (key: string): boolean => export const isKeyPersonaFeature = (key: string): boolean =>
PERSONA_KEY_FEATURE_KEYS.includes(key); PERSONA_KEY_FEATURE_KEYS.includes(key);
/** 首屏排序键 = 白名单下标;不在首屏的排最后(理论上不会被问到) */
export const personaKeyFeatureOrder = (key: string): number => {
const i = PERSONA_KEY_FEATURE_KEYS.indexOf(key);
return i < 0 ? PERSONA_KEY_FEATURE_KEYS.length : i;
};
/** /**
* 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。 * 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
* *
......
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