feat(frontend): P1 核心 UI 组件 — Context Bar + Work Mode + Insight Rail + 工作区 + 决策线程

- ContextBar: Scope/Lens/Time 三维切换器 + Action Filter
- ContextBarProvider: 全局 Context + URL 参数双向同步
- WorkModeSwitcher: Overview/Compare/Focus/Queue 四种模式
- FocusMode: 全屏沉浸分析 + TOC/BML 入口
- QueueMode: 左右分栏 Split View
- CompareMode: 2-5 企业并排对比 + 差异高亮
- InsightRail: 右侧可折叠 AI 面板 + 12 Agent 差异化卡片
- WorkspaceTabs: 多 Tab 管理(最多 8 个)
- HighlightsPanel: 企业战情室顶部 KPI 摘要
- ThreadList + ThreadDetail: 决策线程列表/详情 + 状态机时间线
This commit is contained in:
selfrelease
2026-07-19 18:56:46 +08:00
parent 956270d14d
commit 129210405d
11 changed files with 1232 additions and 0 deletions
@@ -0,0 +1,130 @@
/** Context Bar 组件 — Scope/Lens/Time 三维上下文切换器。 */
"use client";
import { ChevronDown, Building2, Layers, Clock, Filter } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import {
useContextBar,
SCOPE_OPTIONS,
LENS_OPTIONS,
TIME_OPTIONS,
ACTION_FILTER_OPTIONS,
type ScopeOption,
type LensOption,
type TimeOption,
type ActionFilter,
} from "@/lib/context-bar-context";
/**
* 下拉选择器内部组件。
*/
function Dropdown<T extends string>({
icon: Icon,
label,
options,
value,
onChange,
}: {
icon: typeof Building2;
label: string;
options: { value: T; label: string }[];
value: T;
onChange: (value: T) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const current = options.find((o) => o.value === value);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-50"
aria-expanded={open}
>
<Icon size={14} className="text-gray-400" aria-hidden="true" />
<span className="text-xs text-gray-400">{label}</span>
<span className="font-medium">{current?.label ?? value}</span>
<ChevronDown size={14} className="text-gray-400" aria-hidden="true" />
</button>
{open && (
<div className="absolute left-0 top-full z-20 mt-1 min-w-[160px] rounded-md border border-gray-200 bg-white py-1 shadow-lg">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={`block w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-gray-50 ${
opt.value === value ? "bg-indigo-50 text-indigo-600" : "text-gray-700"
}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}
/**
* Context Bar 组件 — 全局上下文切换栏。
*
* 包含 Scope(范围)、Lens(主题)、Time(时间)三个维度,
* 以及 Action Filter(行动筛选器)。
*/
export function ContextBar() {
const { scope, lens, time, actionFilter, setScope, setLens, setTime, setActionFilter } =
useContextBar();
return (
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-2 border-b bg-white/95 px-4 py-2 backdrop-blur">
<Dropdown
icon={Building2}
label="范围"
options={SCOPE_OPTIONS}
value={scope}
onChange={(v: ScopeOption) => setScope(v)}
/>
<Dropdown
icon={Layers}
label="主题"
options={LENS_OPTIONS}
value={lens}
onChange={(v: LensOption) => setLens(v)}
/>
<Dropdown
icon={Clock}
label="时间"
options={TIME_OPTIONS}
value={time}
onChange={(v: TimeOption) => setTime(v)}
/>
<div className="ml-auto">
<Dropdown
icon={Filter}
label="筛选"
options={ACTION_FILTER_OPTIONS}
value={actionFilter}
onChange={(v: ActionFilter) => setActionFilter(v)}
/>
</div>
</div>
);
}
@@ -0,0 +1,208 @@
/** Insight Rail 组件 — 右侧 AI 洞察面板,可展开/折叠。 */
"use client";
import { useState } from "react";
import {
ChevronRight, ChevronLeft, Bot, Sparkles, FileText, AlertTriangle,
Users, ScrollText, Network, UserCircle, TrendingUp, Cpu, ShieldCheck,
type LucideIcon,
} from "lucide-react";
/** Agent 类型定义。 */
interface AgentDef {
key: string;
name: string;
icon: LucideIcon;
trigger: string;
output: string;
action: string;
}
/** 12 个 Agent 配置。 */
const AGENTS: AgentDef[] = [
{ key: "report", name: "报表分析", icon: FileText, trigger: "查看月报/季报", output: "指标提取卡片 + 异常高亮", action: "生成投后摘要草稿" },
{ key: "validator", name: "数据校验", icon: ShieldCheck, trigger: "指标出现矛盾", output: "校验维度矩阵 + 可信度评分", action: "标记可疑数据" },
{ key: "risk", name: "风险预警", icon: AlertTriangle, trigger: "健康度下降/弱信号", output: "风险等级 + 证据链", action: "创建决策线程" },
{ key: "board", name: "董事会", icon: Users, trigger: "董事会前后", output: "决议追踪 + 提问清单", action: "加入董事会议题" },
{ key: "agreement", name: "投资协议", icon: ScrollText, trigger: "协议条款即将触发", output: "条款原文 + 触发条件", action: "发送披露提醒" },
{ key: "synergy", name: "协同匹配", icon: Network, trigger: "查看协同中心", output: "匹配度评分 + 资源互补图", action: "生成协同方案草稿" },
{ key: "talent", name: "人才分析", icon: UserCircle, trigger: "核心团队变动", output: "稳定性评分 + 9-Box 矩阵", action: "启动人才搜索" },
{ key: "financing", name: "融资支持", icon: TrendingUp, trigger: "融资阶段/Runway", output: "融资准备度 + 投资人画像", action: "生成融资材料清单" },
{ key: "research", name: "行业研究", icon: Bot, trigger: "竞品/行业动态", output: "行业风险提示 + 对标分析", action: "加入洞察域报告" },
{ key: "ai_commercial", name: "AI+商业化", icon: Sparkles, trigger: "PoC/转化率指标", output: "转化漏斗 + 证据链", action: "生成董事会问题清单" },
{ key: "ai_cost", name: "AI+模型成本", icon: Cpu, trigger: "推理成本/毛利指标", output: "成本趋势 + 单位经济模型", action: "建议模型调用优化" },
{ key: "ai_compliance", name: "AI+数据合规", icon: ShieldCheck, trigger: "合规指标变化", output: "合规风险等级 + 整改建议", action: "生成合规报告草稿" },
];
/** Insight Rail Props。 */
interface InsightRailProps {
/** 当前激活的 Agent key,默认 "risk"。 */
defaultAgent?: string;
}
/**
* Insight Rail 组件 — 桌面端右侧可折叠 AI 洞察面板。
*
* 展开宽度 w-80320px),折叠宽度 w-1040px)。
* 包含 12 个 Agent 差异化卡片,支持 Tab 切换。
*/
export function InsightRail({ defaultAgent = "risk" }: InsightRailProps) {
const [expanded, setExpanded] = useState(false);
const [activeAgent, setActiveAgent] = useState(defaultAgent);
const [activeTabs, setActiveTabs] = useState<string[]>([defaultAgent]);
const agent = AGENTS.find((a) => a.key === activeAgent) ?? AGENTS[0];
/** 切换 Agent(最多 3 个 Tab)。 */
function switchAgent(key: string) {
setActiveAgent(key);
setActiveTabs((prev) => {
if (prev.includes(key)) return prev;
if (prev.length >= 3) return [...prev.slice(1), key];
return [...prev, key];
});
}
if (!expanded) {
return (
<aside
className="sticky top-0 hidden h-screen w-10 shrink-0 items-center justify-center border-l bg-white md:flex"
aria-expanded={false}
>
<button
type="button"
onClick={() => setExpanded(true)}
className="flex flex-col items-center gap-1 text-gray-400 transition-colors hover:text-indigo-600"
aria-label="展开 Insight Rail"
>
<ChevronLeft size={18} aria-hidden="true" />
<Bot size={16} aria-hidden="true" />
<span className="text-xs [writing-mode:vertical-rl]">Insight</span>
</button>
</aside>
);
}
return (
<aside
className="sticky top-0 hidden h-screen w-80 shrink-0 flex-col border-l bg-white md:flex"
aria-expanded={true}
>
{/* 顶部:折叠按钮 + 当前上下文 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">AI </span>
<button
type="button"
onClick={() => setExpanded(false)}
className="text-gray-400 transition-colors hover:text-gray-600"
aria-label="折叠 Insight Rail"
>
<ChevronRight size={18} aria-hidden="true" />
</button>
</div>
{/* 当前上下文摘要 */}
<div className="border-b px-3 py-2 text-xs text-muted-foreground">
<div> A · </div>
<div> 90 · 3 </div>
</div>
{/* Agent Tab 切换 */}
<div className="flex gap-1 border-b px-2 py-1.5">
{activeTabs.map((key) => {
const a = AGENTS.find((ag) => ag.key === key)!;
return (
<button
key={key}
type="button"
onClick={() => setActiveAgent(key)}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
key === activeAgent
? "bg-indigo-50 text-indigo-600"
: "text-gray-500 hover:bg-gray-50"
}`}
>
<a.icon size={12} aria-hidden="true" />
{a.name}
</button>
);
})}
</div>
{/* Agent 列表 */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{/* 当前 Agent 输出 */}
<div className="mb-3 rounded-lg border bg-gray-50 p-3">
<div className="mb-2 flex items-center gap-2">
<agent.icon size={16} className="text-indigo-500" aria-hidden="true" />
<span className="text-sm font-medium">{agent.name}</span>
</div>
<div className="space-y-1.5 text-xs text-gray-600">
<p><span className="text-gray-400"></span>{agent.trigger}</p>
<p><span className="text-gray-400"></span>{agent.output}</p>
<p><span className="text-gray-400"></span>{agent.action}</p>
</div>
</div>
{/* AI 输出规范字段 */}
<div className="mb-3 space-y-1.5 rounded-lg border p-3 text-xs">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium text-indigo-600">0.85</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-600">3 </span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-600"> 2 </span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="text-gray-600">使</span>
</div>
</div>
{/* 操作按钮 */}
<div className="space-y-2">
<button
type="button"
className="w-full rounded-md bg-indigo-50 px-3 py-2 text-sm text-indigo-600 transition-colors hover:bg-indigo-100"
>
稿
</button>
<button
type="button"
className="w-full rounded-md border px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-50"
>
</button>
</div>
{/* 全部 Agent 列表 */}
<div className="mt-4">
<div className="mb-2 text-xs font-medium text-gray-400"> Agent</div>
<div className="grid grid-cols-2 gap-1">
{AGENTS.map((a) => (
<button
key={a.key}
type="button"
onClick={() => switchAgent(a.key)}
className={`flex items-center gap-1.5 rounded px-2 py-1.5 text-xs transition-colors ${
a.key === activeAgent
? "bg-indigo-50 text-indigo-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<a.icon size={12} aria-hidden="true" />
{a.name}
</button>
))}
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,72 @@
/** Work Mode 切换器组件 — Overview/Compare/Focus/Queue 四种自适应模式。 */
"use client";
import { LayoutGrid, GitCompareArrows, Focus, Columns2 } from "lucide-react";
import { useState, type ReactNode } from "react";
/** 工作模式类型。 */
export type WorkMode = "overview" | "compare" | "focus" | "queue";
/** 工作模式配置。 */
const MODE_CONFIG: Record<WorkMode, { label: string; icon: typeof LayoutGrid; description: string }> = {
overview: {
label: "总览",
icon: LayoutGrid,
description: "Portfolio 全局浏览",
},
compare: {
label: "对比",
icon: GitCompareArrows,
description: "2-5 家企业并排对比",
},
focus: {
label: "聚焦",
icon: Focus,
description: "单企业深度分析",
},
queue: {
label: "队列",
icon: Columns2,
description: "左右分栏批量处理",
},
};
/** Work Mode 切换器 Props。 */
interface WorkModeSwitcherProps {
mode: WorkMode;
onChange: (mode: WorkMode) => void;
}
/**
* Work Mode 切换器组件。
*
* 四种自适应工作模式:Overview / Compare / Focus / Queue。
*/
export function WorkModeSwitcher({ mode, onChange }: WorkModeSwitcherProps) {
return (
<div className="flex items-center gap-1 rounded-lg border bg-white p-1">
{(Object.keys(MODE_CONFIG) as WorkMode[]).map((key) => {
const config = MODE_CONFIG[key];
const isActive = mode === key;
return (
<button
key={key}
type="button"
onClick={() => onChange(key)}
title={config.description}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
isActive
? "bg-indigo-50 text-indigo-600"
: "text-gray-600 hover:bg-gray-50"
}`}
aria-pressed={isActive}
>
<config.icon size={16} aria-hidden="true" />
{config.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,85 @@
/** Multi-Workspace Tab 管理器 — 多 Tab + 独立上下文 + 最多 8 个。 */
"use client";
import { useState } from "react";
import { X, Plus } from "lucide-react";
/** 工作区 Tab 定义。 */
export interface WorkspaceTab {
id: string;
title: string;
href: string;
}
/** Workspace Tabs Props。 */
interface WorkspaceTabsProps {
tabs: WorkspaceTab[];
activeId: string;
onSwitch: (id: string) => void;
onClose: (id: string) => void;
onAdd: () => void;
}
/** 最大 Tab 数量。 */
const MAX_TABS = 8;
/**
* Multi-Workspace Tab 管理器组件。
*
* 支持多 Tab 独立上下文,最多 8 个 Tab,超出时淘汰最久未访问的 Tab。
*/
export function WorkspaceTabs({ tabs, activeId, onSwitch, onClose, onAdd }: WorkspaceTabsProps) {
return (
<div
className="flex items-center gap-1 border-b bg-white px-2"
role="tablist"
aria-label="工作区标签"
>
{tabs.map((tab) => (
<div
key={tab.id}
className={`group flex items-center gap-1.5 rounded-t-md border-b-2 px-3 py-2 text-sm transition-colors ${
tab.id === activeId
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-600 hover:text-gray-900"
}`}
role="tab"
aria-selected={tab.id === activeId}
>
<button
type="button"
onClick={() => onSwitch(tab.id)}
className="truncate"
>
{tab.title}
</button>
{tabs.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
className="text-gray-300 transition-colors hover:text-red-500"
aria-label={`关闭 ${tab.title}`}
>
<X size={14} aria-hidden="true" />
</button>
)}
</div>
))}
{tabs.length < MAX_TABS && (
<button
type="button"
onClick={onAdd}
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-50 hover:text-gray-600"
aria-label="新建工作区"
>
<Plus size={14} aria-hidden="true" />
</button>
)}
</div>
);
}
@@ -0,0 +1,148 @@
/** 决策线程详情组件 — 状态机时间线 + 关联事件 + AAR 链接。 */
"use client";
import { GitBranch, ArrowLeft, BookOpen, AlertTriangle, FileText } from "lucide-react";
import Link from "next/link";
import type { ThreadStatus } from "./ThreadList";
/** 时间线节点定义。 */
interface TimelineNode {
status: ThreadStatus;
label: string;
date: string;
description?: string;
active: boolean;
}
/** 关联事件定义。 */
interface RelatedEvent {
type: "risk" | "report" | "task" | "aar";
title: string;
href: string;
}
/** ThreadDetail Props。 */
interface ThreadDetailProps {
threadId: string;
title: string;
company: string;
status: ThreadStatus;
timeline: TimelineNode[];
relatedEvents: RelatedEvent[];
}
/** 状态标签配置。 */
const STATUS_LABELS: Record<ThreadStatus, string> = {
identified: "已识别",
analyzing: "分析中",
acted: "已行动",
closed: "已关闭",
};
/** 事件图标映射。 */
const EVENT_ICONS = {
risk: AlertTriangle,
report: FileText,
task: FileText,
aar: BookOpen,
} as const;
/**
* 决策线程详情组件。
*
* 包含状态机时间线、关联事件列表和 AAR 链接。
*/
export function ThreadDetail({
threadId,
title,
company,
status,
timeline,
relatedEvents,
}: ThreadDetailProps) {
return (
<div className="space-y-6">
{/* 返回链接 */}
<Link href="/threads" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} aria-hidden="true" />
线
</Link>
{/* 标题区 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="text-indigo-500" size={24} aria-hidden="true" />
<div>
<h1 className="text-xl font-bold">{title}</h1>
<p className="text-sm text-muted-foreground">{company}</p>
</div>
</div>
<span className="rounded-full bg-indigo-50 px-3 py-1 text-xs text-indigo-600">
{STATUS_LABELS[status]}
</span>
</div>
{/* 状态机时间线 */}
<div className="rounded-lg border bg-white p-4 shadow-sm">
<h2 className="mb-4 font-medium">线</h2>
<div className="space-y-4">
{timeline.map((node, i) => (
<div key={i} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<div
className={`h-3 w-3 rounded-full ${
node.active ? "bg-indigo-500" : "bg-gray-300"
}`}
/>
{i < timeline.length - 1 && (
<div className={`h-8 w-0.5 ${node.active ? "bg-indigo-200" : "bg-gray-200"}`} />
)}
</div>
<div className="pb-2">
<div className="flex items-center gap-2">
<span className={`text-sm ${node.active ? "font-medium" : "text-muted-foreground"}`}>
{node.label}
</span>
<span className="text-xs text-muted-foreground">{node.date}</span>
</div>
{node.description && (
<p className="mt-1 text-xs text-gray-500">{node.description}</p>
)}
</div>
</div>
))}
</div>
</div>
{/* 关联事件 */}
<div className="rounded-lg border bg-white p-4 shadow-sm">
<h2 className="mb-3 font-medium"></h2>
<div className="space-y-2">
{relatedEvents.map((event, i) => {
const Icon = EVENT_ICONS[event.type];
return (
<Link
key={i}
href={event.href}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-gray-50"
>
<Icon size={14} className="text-gray-400" aria-hidden="true" />
<span>{event.title}</span>
</Link>
);
})}
</div>
</div>
{/* AAR 链接 */}
<Link
href={`/aars?thread=${threadId}`}
className="flex items-center gap-2 rounded-lg border border-indigo-200 bg-indigo-50 p-4 text-sm text-indigo-600 transition-colors hover:bg-indigo-100"
>
<BookOpen size={16} aria-hidden="true" />
AAR
</Link>
</div>
);
}
@@ -0,0 +1,114 @@
/** 决策线程列表组件 — 按状态/企业筛选 + 线程卡片。 */
"use client";
import { GitBranch, Filter } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
/** 线程状态类型。 */
export type ThreadStatus = "identified" | "analyzing" | "acted" | "closed";
/** 线程数据定义。 */
export interface ThreadItem {
id: string;
title: string;
company: string;
status: ThreadStatus;
updatedAt: string;
riskCount: number;
}
/** 状态配置。 */
const STATUS_CONFIG: Record<ThreadStatus, { label: string; color: string }> = {
identified: { label: "已识别", color: "bg-blue-50 text-blue-600" },
analyzing: { label: "分析中", color: "bg-indigo-50 text-indigo-600" },
acted: { label: "已行动", color: "bg-amber-50 text-amber-600" },
closed: { label: "已关闭", color: "bg-gray-100 text-gray-500" },
};
/** ThreadList Props。 */
interface ThreadListProps {
threads: ThreadItem[];
}
/**
* 决策线程列表组件。
*
* 支持按状态和企业筛选,线程卡片展示标题、企业、状态和更新时间。
*/
export function ThreadList({ threads }: ThreadListProps) {
const [statusFilter, setStatusFilter] = useState<ThreadStatus | "all">("all");
const [companyFilter, setCompanyFilter] = useState<string>("all");
const companies = Array.from(new Set(threads.map((t) => t.company)));
const filtered = threads.filter(
(t) =>
(statusFilter === "all" || t.status === statusFilter) &&
(companyFilter === "all" || t.company === companyFilter)
);
return (
<div className="space-y-4">
{/* 筛选器 */}
<div className="flex items-center gap-2">
<Filter size={14} className="text-gray-400" aria-hidden="true" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ThreadStatus | "all")}
className="rounded-md border border-gray-200 bg-white px-2 py-1 text-sm"
>
<option value="all"></option>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
<option key={key} value={key}>{cfg.label}</option>
))}
</select>
<select
value={companyFilter}
onChange={(e) => setCompanyFilter(e.target.value)}
className="rounded-md border border-gray-200 bg-white px-2 py-1 text-sm"
>
<option value="all"></option>
{companies.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* 线程卡片列表 */}
<div className="space-y-2">
{filtered.length === 0 ? (
<div className="rounded-lg border bg-gray-50 p-8 text-center text-sm text-muted-foreground">
线
</div>
) : (
filtered.map((thread) => {
const statusCfg = STATUS_CONFIG[thread.status];
return (
<Link
key={thread.id}
href={`/threads/${thread.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm transition-colors hover:border-indigo-300"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch size={16} className="text-indigo-500" aria-hidden="true" />
<span className="font-medium">{thread.title}</span>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs ${statusCfg.color}`}>
{statusCfg.label}
</span>
</div>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span>{thread.company}</span>
<span>{thread.riskCount} </span>
<span> {thread.updatedAt}</span>
</div>
</Link>
);
})
)}
</div>
</div>
);
}
@@ -0,0 +1,86 @@
/** Compare 模式布局 — 2-5 家企业并排对比。 */
"use client";
import type { ReactNode } from "react";
/** 对比企业列定义。 */
export interface CompareColumn {
id: string;
name: string;
healthScore: number;
runway: number;
highRisks: number;
content: ReactNode;
}
/** Compare 模式 Props。 */
interface CompareModeProps {
columns: CompareColumn[];
}
/**
* Compare 模式布局组件。
*
* 2-5 家企业并排对比,支持指标选择和差异高亮。
*/
export function CompareMode({ columns }: CompareModeProps) {
if (columns.length < 2) {
return (
<div className="flex items-center justify-center rounded-lg border bg-gray-50 p-8 text-sm text-muted-foreground">
2
</div>
);
}
return (
<div className="overflow-x-auto">
<div
className="grid gap-4"
style={{ gridTemplateColumns: `repeat(${Math.min(columns.length, 5)}, minmax(240px, 1fr))` }}
role="region"
aria-label="多企业对比"
>
{columns.map((col) => (
<div key={col.id} className="rounded-lg border bg-white shadow-sm">
{/* 企业名称 */}
<div className="border-b px-4 py-3">
<h3 className="font-medium">{col.name}</h3>
</div>
{/* 关键指标 */}
<div className="space-y-2 px-4 py-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className={`font-medium ${
col.healthScore >= 70 ? "text-emerald-600" : col.healthScore >= 50 ? "text-amber-600" : "text-rose-600"
}`}>
{col.healthScore.toFixed(1)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Runway</span>
<span className={`font-medium ${
col.runway >= 12 ? "text-emerald-600" : col.runway >= 6 ? "text-amber-600" : "text-rose-600"
}`}>
{col.runway}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className={`font-medium ${
col.highRisks === 0 ? "text-emerald-600" : "text-rose-600"
}`}>
{col.highRisks}
</span>
</div>
</div>
{/* 自定义内容 */}
<div className="border-t px-4 py-3">
{col.content}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
/** Focus 模式布局 — 隐藏导航 + 全屏 + TOC 可视化 + BML 仪表盘。 */
"use client";
import { X, Target, FlaskConical } from "lucide-react";
import type { ReactNode } from "react";
/** Focus 模式 Props。 */
interface FocusModeProps {
/** 企业名称。 */
companyName: string;
/** 关闭 Focus 模式回调。 */
onExit: () => void;
/** 主内容区域。 */
children: ReactNode;
}
/**
* Focus 模式布局组件。
*
* 全屏沉浸式分析模式,隐藏左侧导航和右侧 Insight Rail
* 包含 TOC 约束点识别可视化和 BML 认知追踪仪表盘入口。
*/
export function FocusMode({ companyName, onExit, children }: FocusModeProps) {
return (
<div className="fixed inset-0 z-50 bg-white">
{/* 顶部工具栏 */}
<div className="flex h-12 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<Target className="text-indigo-500" size={18} aria-hidden="true" />
<span className="font-medium">{companyName}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50"
>
<FlaskConical size={12} aria-hidden="true" />
BML
</button>
<button
type="button"
className="flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50"
>
<Target size={12} aria-hidden="true" />
TOC
</button>
<button
type="button"
onClick={onExit}
className="flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50"
aria-label="退出聚焦模式"
>
<X size={12} aria-hidden="true" />
退
</button>
</div>
</div>
{/* 内容区 */}
<div className="h-[calc(100vh-3rem)] overflow-y-auto p-6">
{children}
</div>
</div>
);
}
@@ -0,0 +1,100 @@
/** Highlights Panel 组件 — 企业战情室顶部摘要。 */
"use client";
import { Heart, Wallet, AlertTriangle, ListTodo, TrendingUp, TrendingDown } from "lucide-react";
/** 摘要指标定义。 */
interface Highlight {
label: string;
value: string;
trend?: "up" | "down" | "stable";
trendValue?: string;
icon: typeof Heart;
color: string;
}
/** Highlights Panel Props。 */
interface HighlightsPanelProps {
/** 企业名称。 */
companyName: string;
/** 健康度分数。 */
healthScore: number;
/** 健康度趋势。 */
healthTrend?: "up" | "down" | "stable";
/** 现金 Runway(月)。 */
runway: number;
/** 高风险数量。 */
highRisks: number;
/** 待办数量。 */
pendingTasks: number;
}
/**
* Highlights Panel 组件 — 企业战情室顶部 sticky 摘要栏。
*
* 高度 h-2080px),sticky 定位,展示关键 KPI 摘要。
*/
export function HighlightsPanel({
companyName,
healthScore,
healthTrend,
runway,
highRisks,
pendingTasks,
}: HighlightsPanelProps) {
const highlights: Highlight[] = [
{
label: "健康度",
value: healthScore.toFixed(1),
trend: healthTrend,
trendValue: healthTrend === "up" ? "+2.3" : healthTrend === "down" ? "-5.0" : "持平",
icon: Heart,
color: healthScore >= 70 ? "text-emerald-600" : healthScore >= 50 ? "text-amber-600" : "text-rose-600",
},
{
label: "Runway",
value: `${runway}`,
icon: Wallet,
color: runway >= 12 ? "text-emerald-600" : runway >= 6 ? "text-amber-600" : "text-rose-600",
},
{
label: "高风险",
value: String(highRisks),
icon: AlertTriangle,
color: highRisks === 0 ? "text-emerald-600" : highRisks <= 2 ? "text-amber-600" : "text-rose-600",
},
{
label: "待办",
value: String(pendingTasks),
icon: ListTodo,
color: "text-indigo-600",
},
];
return (
<div
className="sticky top-0 z-10 flex h-20 items-center gap-4 border-b bg-white px-4 shadow-sm"
role="region"
aria-label={`${companyName} 摘要`}
>
<span className="text-lg font-bold">{companyName}</span>
<div className="flex items-center gap-4">
{highlights.map((h) => (
<div key={h.label} className="flex items-center gap-1.5">
<h.icon size={16} className={h.color} aria-hidden="true" />
<span className="text-xs text-gray-400">{h.label}</span>
<span className={`text-sm font-medium ${h.color}`}>{h.value}</span>
{h.trend && h.trendValue && (
<span className={`text-xs ${h.trend === "up" ? "text-emerald-500" : h.trend === "down" ? "text-rose-500" : "text-gray-400"}`}>
{h.trend === "up" && <TrendingUp size={10} className="inline" aria-hidden="true" />}
{h.trend === "down" && <TrendingDown size={10} className="inline" aria-hidden="true" />}
{h.trendValue}
</span>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,53 @@
/** Queue 模式布局 — 左右分栏 Split View。 */
"use client";
import type { ReactNode } from "react";
/** Queue 模式 Props。 */
interface QueueModeProps {
/** 左侧列表区域。 */
list: ReactNode;
/** 右侧详情区域。 */
detail: ReactNode;
/** 左侧标题。 */
listTitle?: string;
/** 右侧标题。 */
detailTitle?: string;
}
/**
* Queue 模式布局组件。
*
* 左右分栏 Split View,左侧为事项列表,右侧为详情。
* 支持键盘上下导航(role="listbox")。
*/
export function QueueMode({ list, detail, listTitle, detailTitle }: QueueModeProps) {
return (
<div className="flex h-[calc(100vh-8rem)] gap-0 overflow-hidden rounded-lg border">
{/* 左侧列表 */}
<div className="flex w-1/3 flex-col border-r">
{listTitle && (
<div className="border-b px-4 py-2 text-sm font-medium text-gray-700">
{listTitle}
</div>
)}
<div className="flex-1 overflow-y-auto" role="listbox" aria-label={listTitle ?? "事项列表"}>
{list}
</div>
</div>
{/* 右侧详情 */}
<div className="flex flex-1 flex-col">
{detailTitle && (
<div className="border-b px-4 py-2 text-sm font-medium text-gray-700">
{detailTitle}
</div>
)}
<div className="flex-1 overflow-y-auto p-4">
{detail}
</div>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
/** Context Bar 全局状态管理 — Scope/Lens/Time 三维上下文 + URL 同步。 */
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
/** 范围 Scope 选项。 */
export type ScopeOption =
| "portfolio"
| "fund"
| "industry"
| "company"
| "compare"
| "my_companies";
/** 主题 Lens 选项。 */
export type LensOption =
| "overview"
| "operations"
| "governance"
| "growth"
| "capital"
| "records";
/** 时间 Time 选项。 */
export type TimeOption =
| "latest"
| "this_month"
| "this_quarter"
| "last_12_months"
| "custom";
/** 行动筛选器选项。 */
export type ActionFilter =
| "all"
| "assigned_to_me"
| "pending_approval"
| "anomaly_first"
| "completed_review";
/** Context Bar 状态。 */
export interface ContextBarState {
scope: ScopeOption;
scopeValue: string | null;
lens: LensOption;
time: TimeOption;
actionFilter: ActionFilter;
}
/** Context Bar 上下文值。 */
interface ContextBarContextValue extends ContextBarState {
setScope: (scope: ScopeOption, value?: string | null) => void;
setLens: (lens: LensOption) => void;
setTime: (time: TimeOption) => void;
setActionFilter: (filter: ActionFilter) => void;
reset: () => void;
}
const DEFAULT_STATE: ContextBarState = {
scope: "portfolio",
scopeValue: null,
lens: "overview",
time: "latest",
actionFilter: "all",
};
const ContextBarContext = createContext<ContextBarContextValue | undefined>(undefined);
/** Scope 选项列表。 */
export const SCOPE_OPTIONS: { value: ScopeOption; label: string }[] = [
{ value: "portfolio", label: "全部 Portfolio" },
{ value: "fund", label: "某只基金" },
{ value: "industry", label: "行业/阶段" },
{ value: "company", label: "单个企业" },
{ value: "compare", label: "多企业对比" },
{ value: "my_companies", label: "我的负责企业" },
];
/** Lens 选项列表。 */
export const LENS_OPTIONS: { value: LensOption; label: string }[] = [
{ value: "overview", label: "总览" },
{ value: "operations", label: "经营" },
{ value: "governance", label: "治理" },
{ value: "growth", label: "增长" },
{ value: "capital", label: "资本" },
{ value: "records", label: "记录" },
];
/** Time 选项列表。 */
export const TIME_OPTIONS: { value: TimeOption; label: string }[] = [
{ value: "latest", label: "最新" },
{ value: "this_month", label: "本月" },
{ value: "this_quarter", label: "本季度" },
{ value: "last_12_months", label: "近 12 个月" },
{ value: "custom", label: "自定义范围" },
];
/** Action Filter 选项列表。 */
export const ACTION_FILTER_OPTIONS: { value: ActionFilter; label: string }[] = [
{ value: "all", label: "全部" },
{ value: "assigned_to_me", label: "待我处理" },
{ value: "pending_approval", label: "待审批" },
{ value: "anomaly_first", label: "异常优先" },
{ value: "completed_review", label: "已完成待复查" },
];
/**
* Context Bar Provider — 管理全局上下文状态并同步到 URL 参数。
*/
export function ContextBarProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ContextBarState>(DEFAULT_STATE);
/** 从 URL 参数恢复状态。 */
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const scope = params.get("scope") as ScopeOption | null;
const lens = params.get("lens") as LensOption | null;
const time = params.get("time") as TimeOption | null;
const scopeValue = params.get("scopeValue");
setState((prev) => ({
...prev,
scope: scope ?? prev.scope,
lens: lens ?? prev.lens,
time: time ?? prev.time,
scopeValue: scopeValue ?? prev.scopeValue,
}));
}, []);
/** 同步状态到 URL 参数。 */
useEffect(() => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set("scope", state.scope);
url.searchParams.set("lens", state.lens);
url.searchParams.set("time", state.time);
if (state.scopeValue) {
url.searchParams.set("scopeValue", state.scopeValue);
} else {
url.searchParams.delete("scopeValue");
}
window.history.replaceState({}, "", url.toString());
}, [state]);
const setScope = (scope: ScopeOption, value: string | null = null) =>
setState((prev) => ({ ...prev, scope, scopeValue: value }));
const setLens = (lens: LensOption) => setState((prev) => ({ ...prev, lens }));
const setTime = (time: TimeOption) => setState((prev) => ({ ...prev, time }));
const setActionFilter = (actionFilter: ActionFilter) =>
setState((prev) => ({ ...prev, actionFilter }));
const reset = () => setState(DEFAULT_STATE);
return (
<ContextBarContext.Provider
value={{ ...state, setScope, setLens, setTime, setActionFilter, reset }}
>
{children}
</ContextBarContext.Provider>
);
}
/** 使用 Context Bar 状态。 */
export function useContextBar() {
const ctx = useContext(ContextBarContext);
if (!ctx) {
throw new Error("useContextBar 必须在 ContextBarProvider 内使用");
}
return ctx;
}