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