圆桌第二步支持按席位筛选会商记录,左侧角色卡与消息头像联动。
新增 seat-filter 归一化匹配逻辑,点击角色卡或气泡头像可只看该席位发言,并支持一键显示全部。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
9002ae157a
commit
dd329e7a4f
3 files changed
+350
-36
No files matched your search
@@ -68,6 +68,12 @@ import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeat
|
||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import {
|
||||
seatFilterForSender,
|
||||
seatFilterLabel,
|
||||
senderMatchesSeatFilter,
|
||||
type SeatFilter,
|
||||
} from "@/roundtable-planning/lib/seat-filter";
|
||||
import type {
|
||||
AgentConfig,
|
||||
ParallelSeatView,
|
||||
@@ -100,6 +106,10 @@ export interface Step2PanelProps {
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
selectedAgentId: number | null;
|
||||
roles: RoleEntry[];
|
||||
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
|
||||
/** 当前筛选标识;null = 显示全部。 */
|
||||
seatFilter: SeatFilter | null;
|
||||
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
|
||||
// ─── 并行显示(§5)────────────────────────────────────────────
|
||||
parallelBatch: ParallelSeatView[];
|
||||
selectedParallelSeats: string[];
|
||||
@@ -161,6 +171,8 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
agentConfigs,
|
||||
selectedAgentId,
|
||||
roles,
|
||||
seatFilter,
|
||||
setSeatFilter,
|
||||
parallelBatch,
|
||||
selectedParallelSeats,
|
||||
isParallelRunning,
|
||||
@@ -201,6 +213,36 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
|
||||
|
||||
// ─── 按席位筛选会商记录 ──────────────────────────────────────────────────
|
||||
// 筛选状态在页面层(与左侧「参与角色 / 人工参与」卡片共用)。点卡片或点气泡头像
|
||||
// 都把目标归一化成同一 SeatFilter 标识(见 lib/seat-filter),命中 = 标识相等。
|
||||
// 仅在单列(非并行)视图启用;并行进行时主区另有「头像条选席位」机制,互不干扰。
|
||||
const toggleSeatFilter = useCallback(
|
||||
(filter: SeatFilter) =>
|
||||
setSeatFilter((cur) => (cur === filter ? null : filter)),
|
||||
[setSeatFilter],
|
||||
);
|
||||
const visibleDialogues = useMemo(
|
||||
() =>
|
||||
seatFilter
|
||||
? step2RoundtableDialogues.filter((d) =>
|
||||
senderMatchesSeatFilter(d.sender, seatFilter),
|
||||
)
|
||||
: step2RoundtableDialogues,
|
||||
[seatFilter, step2RoundtableDialogues],
|
||||
);
|
||||
// 选中的席位若已不在对话里(切换草稿 / 重置后),自动清除筛选,避免卡在空列表。
|
||||
useEffect(() => {
|
||||
if (
|
||||
seatFilter &&
|
||||
!step2RoundtableDialogues.some((d) =>
|
||||
senderMatchesSeatFilter(d.sender, seatFilter),
|
||||
)
|
||||
) {
|
||||
setSeatFilter(null);
|
||||
}
|
||||
}, [seatFilter, step2RoundtableDialogues, setSeatFilter]);
|
||||
|
||||
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
|
||||
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
|
||||
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
|
||||
@@ -485,20 +527,55 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
step2RoundtableDialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||
resolveAgentName={resolveAgentName}
|
||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) {
|
||||
openArtifactPreview(msg.agentThreadId, path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */}
|
||||
{seatFilter && (
|
||||
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between gap-2 rounded-lg border border-primary/20 bg-[var(--brand-200)]/20 px-3 py-1.5 backdrop-blur-sm">
|
||||
<span className="roundtable-chat-meta font-medium text-slate-600">
|
||||
仅显示{" "}
|
||||
<span className="font-bold text-primary">
|
||||
{seatFilterLabel(seatFilter)}
|
||||
</span>{" "}
|
||||
的发言
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeatFilter(null)}
|
||||
className="roundtable-chat-meta inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 font-semibold text-primary transition hover:bg-primary/10"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
显示全部
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{seatFilter && visibleDialogues.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-slate-400">
|
||||
没有匹配「{seatFilterLabel(seatFilter)}」的消息
|
||||
</div>
|
||||
) : (
|
||||
visibleDialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||
isAvatarSelected={
|
||||
seatFilter !== null &&
|
||||
senderMatchesSeatFilter(msg.sender, seatFilter)
|
||||
}
|
||||
onAvatarClick={() =>
|
||||
toggleSeatFilter(seatFilterForSender(msg.sender))
|
||||
}
|
||||
resolveAgentName={resolveAgentName}
|
||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) {
|
||||
openArtifactPreview(msg.agentThreadId, path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||
@@ -706,6 +783,13 @@ export interface Step2MessageProps {
|
||||
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
|
||||
*/
|
||||
metric?: MessageMetric;
|
||||
/**
|
||||
* 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等
|
||||
* 场景)则头像为纯展示,保持原样。
|
||||
*/
|
||||
onAvatarClick?: () => void;
|
||||
/** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */
|
||||
isAvatarSelected?: boolean;
|
||||
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
||||
resolveAgentName?: (idOrName: string) => string;
|
||||
onArtifactStepClick?: (
|
||||
@@ -718,6 +802,8 @@ export interface Step2MessageProps {
|
||||
export function Step2Message({
|
||||
msg,
|
||||
metric,
|
||||
onAvatarClick,
|
||||
isAvatarSelected = false,
|
||||
resolveAgentName,
|
||||
onArtifactStepClick,
|
||||
onFileCardClick,
|
||||
@@ -732,9 +818,32 @@ export function Step2Message({
|
||||
const hasContent = content.length > 0;
|
||||
const presentedFiles = collectPresentedFiles(msg.steps);
|
||||
|
||||
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
|
||||
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
|
||||
const avatar = onAvatarClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAvatarClick}
|
||||
aria-pressed={isAvatarSelected}
|
||||
title={
|
||||
isAvatarSelected
|
||||
? "取消筛选,显示全部发言"
|
||||
: `只看 ${msg.sender} 的发言`
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||
isAvatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
|
||||
)}
|
||||
>
|
||||
{avatarInner}
|
||||
</button>
|
||||
) : (
|
||||
avatarInner
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
|
||||
avatar={avatar}
|
||||
senderName={msg.sender}
|
||||
badge={msg.confidence}
|
||||
time={msg.time}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 「按席位筛选会商记录」的匹配逻辑 —— 第二步左侧「参与角色 / 人工参与」卡片
|
||||
* 与消息气泡头像共用同一套标识。
|
||||
*
|
||||
* 设计要点:把**任意一条气泡的 sender** 和**任意一张卡片**都归一化成同一个
|
||||
* `SeatFilter` 字符串标识,于是「某气泡命中当前筛选」就等价于「两边标识相等」,
|
||||
* 卡片点击与头像点击不再各写一套匹配规则。
|
||||
*
|
||||
* 三类标识:
|
||||
* - `"coordinator"` —— 总控协调 / 系统 调度播报(对应「指挥员 / 主持人」卡片)
|
||||
* - `"user"` —— 用户插话 / 澄清回复(对应「人工参与 · 用户」卡片)
|
||||
* - `"agent:<显示名>"` —— 某 AI 席位的发言(对应该席位卡片)
|
||||
*/
|
||||
import { COMMANDER_SEAT_ID, type RoleEntry } from "./roundtable-constants";
|
||||
|
||||
export type SeatFilter = string;
|
||||
|
||||
/** 总控协调气泡固定使用的 sender(见 useStep2Orchestration 的 appendStreamingDialogue)。 */
|
||||
export const COORDINATOR_SENDER = "总控协调";
|
||||
|
||||
export const COORDINATOR_SEAT_FILTER: SeatFilter = "coordinator";
|
||||
export const USER_SEAT_FILTER: SeatFilter = "user";
|
||||
|
||||
const AGENT_PREFIX = "agent:";
|
||||
|
||||
/** 把一条气泡的 sender 归一化成筛选标识。 */
|
||||
export function seatFilterForSender(sender: string): SeatFilter {
|
||||
if (sender === COORDINATOR_SENDER || sender === "系统") {
|
||||
return COORDINATOR_SEAT_FILTER;
|
||||
}
|
||||
if (sender.startsWith("用户")) {
|
||||
return USER_SEAT_FILTER;
|
||||
}
|
||||
// 去掉「方案设计 (deepseek-…)」这种「模型偏好已保存」播报的模型后缀,回到席位基名,
|
||||
// 让它与该席位的正常发言归到同一标识。
|
||||
const base = sender.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
||||
return `${AGENT_PREFIX}${base}`;
|
||||
}
|
||||
|
||||
/** 把「参与角色」里的一行(席位)映射成筛选标识。 */
|
||||
export function seatFilterForRole(role: RoleEntry): SeatFilter {
|
||||
// 指挥员 / 主持人(COMMANDER_SEAT_ID,human):对应总控协调 / 系统 调度播报流。
|
||||
if (role.type === "human" || role.id === COMMANDER_SEAT_ID) {
|
||||
return COORDINATOR_SEAT_FILTER;
|
||||
}
|
||||
return `${AGENT_PREFIX}${role.name}`;
|
||||
}
|
||||
|
||||
/** 某 sender 是否命中当前筛选。 */
|
||||
export function senderMatchesSeatFilter(
|
||||
sender: string,
|
||||
filter: SeatFilter,
|
||||
): boolean {
|
||||
return seatFilterForSender(sender) === filter;
|
||||
}
|
||||
|
||||
/** 筛选提示条用的人类可读标签。 */
|
||||
export function seatFilterLabel(filter: SeatFilter): string {
|
||||
if (filter === COORDINATOR_SEAT_FILTER) return "指挥员 / 主持人";
|
||||
if (filter === USER_SEAT_FILTER) return "用户";
|
||||
if (filter.startsWith(AGENT_PREFIX)) return filter.slice(AGENT_PREFIX.length);
|
||||
return filter;
|
||||
}
|
||||
@@ -86,6 +86,11 @@ import {
|
||||
type RoleEntry,
|
||||
type SelectedAgent,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import {
|
||||
seatFilterForRole,
|
||||
USER_SEAT_FILTER,
|
||||
type SeatFilter,
|
||||
} from "@/roundtable-planning/lib/seat-filter";
|
||||
import {
|
||||
buildChainSeatSelection,
|
||||
type SeatSelectionResult,
|
||||
@@ -215,6 +220,8 @@ export default function RoundtablePlanningPage() {
|
||||
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
||||
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
|
||||
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
|
||||
// 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。
|
||||
const [seatFilter, setSeatFilter] = useState<SeatFilter | null>(null);
|
||||
|
||||
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
|
||||
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
|
||||
@@ -1153,6 +1160,8 @@ export default function RoundtablePlanningPage() {
|
||||
step2Runs={step2Runs}
|
||||
activeStep2RunId={activeStep2RunId}
|
||||
onSelectStep2Run={handleSelectStep2Run}
|
||||
seatFilter={seatFilter}
|
||||
setSeatFilter={setSeatFilter}
|
||||
/>
|
||||
|
||||
{/* MIDDLE VIEWPORT */}
|
||||
@@ -1216,6 +1225,8 @@ export default function RoundtablePlanningPage() {
|
||||
agentConfigs={step2.agentConfigs}
|
||||
selectedAgentId={step2.selectedAgentId}
|
||||
roles={roles}
|
||||
seatFilter={seatFilter}
|
||||
setSeatFilter={setSeatFilter}
|
||||
parallelBatch={step2.parallelBatch}
|
||||
selectedParallelSeats={step2.selectedParallelSeats}
|
||||
isParallelRunning={step2.isParallelRunning}
|
||||
@@ -1623,6 +1634,9 @@ interface LeftSidebarProps {
|
||||
step2Runs: DraftStep2RunSnapshot[];
|
||||
activeStep2RunId: string | null;
|
||||
onSelectStep2Run: (runId: string) => void;
|
||||
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
|
||||
seatFilter: SeatFilter | null;
|
||||
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
|
||||
}
|
||||
|
||||
function Step2SandboxSidebarSync({
|
||||
@@ -1662,8 +1676,13 @@ function LeftSidebar({
|
||||
step2Runs,
|
||||
activeStep2RunId,
|
||||
onSelectStep2Run,
|
||||
seatFilter,
|
||||
setSeatFilter,
|
||||
}: LeftSidebarProps) {
|
||||
const isStep2 = currentStep === 2;
|
||||
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
|
||||
const toggleSeatFilter = (filter: SeatFilter) =>
|
||||
setSeatFilter((cur) => (cur === filter ? null : filter));
|
||||
|
||||
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
|
||||
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
|
||||
@@ -1708,13 +1727,25 @@ function LeftSidebar({
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
|
||||
</div>
|
||||
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||
{roles.map((role) => (
|
||||
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
|
||||
))}
|
||||
{roles.map((role) => {
|
||||
const filter = seatFilterForRole(role);
|
||||
return (
|
||||
<RoleRow
|
||||
key={role.id}
|
||||
role={role}
|
||||
agentNameToRole={agentNameToRole}
|
||||
active={seatFilter === filter}
|
||||
onSelect={() => toggleSeatFilter(filter)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HumanParticipationCard />
|
||||
<HumanParticipationCard
|
||||
active={seatFilter === USER_SEAT_FILTER}
|
||||
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
|
||||
/>
|
||||
<Step2RunSwitcher
|
||||
runs={step2Runs}
|
||||
activeRunId={activeStep2RunId}
|
||||
@@ -1730,20 +1761,41 @@ function LeftSidebar({
|
||||
|
||||
<CollapsedSidebarSection title="参与角色">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{roles.map((role) => (
|
||||
<RoleAvatarBadge
|
||||
key={role.id}
|
||||
role={role}
|
||||
agentNameToRole={agentNameToRole}
|
||||
size="h-8 w-8"
|
||||
showSpeakingAnimation
|
||||
/>
|
||||
))}
|
||||
{roles.map((role) => {
|
||||
const filter = seatFilterForRole(role);
|
||||
return (
|
||||
<RoleAvatarBadge
|
||||
key={role.id}
|
||||
role={role}
|
||||
agentNameToRole={agentNameToRole}
|
||||
size="h-8 w-8"
|
||||
showSpeakingAnimation
|
||||
active={seatFilter === filter}
|
||||
onSelect={() => toggleSeatFilter(filter)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsedSidebarSection>
|
||||
|
||||
<CollapsedSidebarSection title="人工参与">
|
||||
<Man1Avatar size="h-8 w-8" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
|
||||
aria-pressed={seatFilter === USER_SEAT_FILTER}
|
||||
title={
|
||||
seatFilter === USER_SEAT_FILTER
|
||||
? "取消筛选,显示全部发言"
|
||||
: "只看 用户 的发言"
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||
seatFilter === USER_SEAT_FILTER &&
|
||||
"ring-2 ring-primary ring-offset-2 ring-offset-white",
|
||||
)}
|
||||
>
|
||||
<Man1Avatar size="h-8 w-8" />
|
||||
</button>
|
||||
</CollapsedSidebarSection>
|
||||
|
||||
<CollapsedSidebarSection title="任务概览">
|
||||
@@ -1897,14 +1949,50 @@ function TaskOverviewCard({ currentStep }: { currentStep: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HumanParticipationCard() {
|
||||
function HumanParticipationCard({
|
||||
active = false,
|
||||
onSelect,
|
||||
}: {
|
||||
/** 当前筛选是否命中「用户」(命中则高亮)。 */
|
||||
active?: boolean;
|
||||
/** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */
|
||||
onSelect?: () => void;
|
||||
} = {}) {
|
||||
const clickable = Boolean(onSelect);
|
||||
return (
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
|
||||
</div>
|
||||
<div className="flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3">
|
||||
<div
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
aria-pressed={clickable ? active : undefined}
|
||||
title={
|
||||
clickable
|
||||
? active
|
||||
? "取消筛选,显示全部发言"
|
||||
: "只看 用户 的发言"
|
||||
: undefined
|
||||
}
|
||||
onClick={onSelect}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
|
||||
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
|
||||
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Man1Avatar size="h-10 w-10" />
|
||||
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
|
||||
@@ -2318,6 +2406,8 @@ function RoleAvatarBadge({
|
||||
agentNameToRole,
|
||||
size = "h-7 w-7",
|
||||
showSpeakingAnimation = false,
|
||||
active = false,
|
||||
onSelect,
|
||||
}: {
|
||||
role: RoleEntry;
|
||||
agentNameToRole: Record<
|
||||
@@ -2326,16 +2416,21 @@ function RoleAvatarBadge({
|
||||
>;
|
||||
size?: string;
|
||||
showSpeakingAnimation?: boolean;
|
||||
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
|
||||
active?: boolean;
|
||||
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
const isOngoing = role.status === "研讨中";
|
||||
const animate = showSpeakingAnimation && isOngoing;
|
||||
const clickable = Boolean(onSelect);
|
||||
|
||||
return (
|
||||
const inner = (
|
||||
<div
|
||||
title={`${role.name} · ${role.status}`}
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-full",
|
||||
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
|
||||
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
|
||||
)}
|
||||
>
|
||||
{animate && (
|
||||
@@ -2346,33 +2441,80 @@ function RoleAvatarBadge({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!clickable) {
|
||||
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
|
||||
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleRow({
|
||||
role,
|
||||
agentNameToRole,
|
||||
active = false,
|
||||
onSelect,
|
||||
}: {
|
||||
role: RoleEntry;
|
||||
agentNameToRole: Record<
|
||||
string,
|
||||
{ speakerId: number; avatarType: string; displayName: string }
|
||||
>;
|
||||
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
|
||||
active?: boolean;
|
||||
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
|
||||
onSelect?: () => void;
|
||||
}) {
|
||||
const isActive =
|
||||
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
|
||||
const isOngoing = role.status === "研讨中";
|
||||
const isBootingUp = role.status === "正在上线";
|
||||
const clickable = Boolean(onSelect);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`role-item-${role.id}`}
|
||||
className={`flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition ${
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
aria-pressed={clickable ? active : undefined}
|
||||
title={
|
||||
clickable
|
||||
? active
|
||||
? "取消筛选,显示全部发言"
|
||||
: `只看 ${role.name} 的发言`
|
||||
: undefined
|
||||
}
|
||||
onClick={onSelect}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
|
||||
isActive
|
||||
? "bg-slate-50 border border-slate-100"
|
||||
: isBootingUp
|
||||
? "bg-amber-50/60 border border-amber-100"
|
||||
: "opacity-60"
|
||||
}`}
|
||||
: "opacity-60",
|
||||
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
|
||||
active &&
|
||||
"ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
|
||||
|
||||
Reference in new issue
Block a user