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