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:
@@ -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-80(320px),折叠宽度 w-10(40px)。
|
||||||
|
* 包含 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-20(80px),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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user