docs(uiux): UIUX 设计方案大改 + 5 份作业指导书对齐 + 开发任务文档

- UIUX 文档:填充 19 个缺口(多主体画像/健康度/AI+看板/增长域/洞察域/创始人端/OODA/助推/商密)
- UIUX 文档:插入 6 个新章节(十四~十九),旧章节重编号为二十~三十一,更新目录和交叉引用
- 作业指导书 x5:导航改为 6 域分组,新增 Context Bar/工作模式/Insight Rail/决策线程/多工作区等 UI 概念
- 新建 docs/2-task-uiux.md:50 个代码落地开发任务,按 P0-P6 分优先级 + 8 Sprint 规划
- 后端/前端:大量新增模型、路由、组件(来自之前 Phase 开发)
This commit is contained in:
selfrelease
2026-07-19 11:53:38 +08:00
parent 734a16a7f3
commit fad458b2a7
243 changed files with 19898 additions and 658 deletions
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import { BookOpen, Sparkles } from "lucide-react";
import { listAARs, generateAAR } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function AARsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [triggerEvent, setTriggerEvent] = useState("");
const [originalPlan, setOriginalPlan] = useState("");
const [actualResult, setActualResult] = useState("");
useEffect(() => {
listAARs()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
const handleGenerate = async () => {
if (!triggerEvent.trim()) {
toast.error("请输入触发事件");
return;
}
try {
const resp = await generateAAR(triggerEvent, originalPlan, actualResult);
setItems([resp.data as Record<string, any>, ...items]);
toast.success("AAR 复盘已生成");
} catch {
toast.error("生成失败");
}
};
return (
<PageContainer title="AAR 系统化复盘" description="AI 五问复盘 — 发生了什么/为什么/什么做得好/什么没做好/下次怎么改">
<Card>
<h3 className="font-medium text-gray-900"> AAR </h3>
<input
type="text"
value={triggerEvent}
onChange={(e) => setTriggerEvent(e.target.value)}
placeholder="触发事件"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<input
type="text"
value={originalPlan}
onChange={(e) => setOriginalPlan(e.target.value)}
placeholder="原计划"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<input
type="text"
value={actualResult}
onChange={(e) => setActualResult(e.target.value)}
placeholder="实际结果"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<button
onClick={handleGenerate}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} /> AI
</button>
</Card>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无 AAR 复盘记录" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<h3 className="font-medium text-gray-900">{item.trigger_event as string}</h3>
{item.gap_analysis && <p className="mt-1 text-sm text-gray-600">{item.gap_analysis as string}</p>}
{Array.isArray(item.lessons) && (
<div className="mt-2 space-y-1">
{(item.lessons as string[]).map((lesson, li) => (
<p key={li} className="text-xs text-gray-500"> {lesson}</p>
))}
</div>
)}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { Bot } from "lucide-react";
import { listAgentExecutions, reviewAgentExecution } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function AgentsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = () => {
listAgentExecutions()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
};
useEffect(() => { load(); }, []);
const handleReview = async (id: string, status: string) => {
try {
await reviewAgentExecution(id, status);
toast.success("审核完成");
load();
} catch {
toast.error("审核失败");
}
};
return (
<PageContainer title="Agent 监控" description="L1-L4 分级自治执行 + 人工审核">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无 Agent 执行记录" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500">Agent</th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.agent_name as string}</td>
<td className="px-4 py-2"><Badge color={item.autonomy_level === "L4" ? "red" : item.autonomy_level === "L3" ? "amber" : "blue"}>{item.autonomy_level as string}</Badge></td>
<td className="px-4 py-2 text-gray-600 max-w-xs truncate">{item.output_summary as string}</td>
<td className="px-4 py-2 text-gray-500">{item.duration_ms ? `${item.duration_ms}ms` : "-"}</td>
<td className="px-4 py-2">
<Badge color={item.review_status === "approved" ? "green" : item.review_status === "rejected" ? "red" : "amber"}>
{item.review_status as string}
</Badge>
</td>
<td className="px-4 py-2">
{item.review_status === "pending" && (
<div className="flex gap-1">
<button
onClick={() => handleReview(item.id as string, "approved")}
className="rounded bg-emerald-600 px-2 py-0.5 text-xs text-white hover:bg-emerald-700"
>
</button>
<button
onClick={() => handleReview(item.id as string, "rejected")}
className="rounded bg-rose-600 px-2 py-0.5 text-xs text-white hover:bg-rose-700"
>
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { ScrollText, Plus } from "lucide-react";
import { listAgreements } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function AgreementsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listAgreements()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer
title="投资协议监控"
description="AI 解析协议条款 → 持续监控触发条件 → 预警"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} />
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无投资协议" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.length === 0 ? (
<TableEmpty colSpan={4} />
) : (
items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.title as string}</td>
<td className="px-4 py-2 text-gray-600">{item.signed_at as string}</td>
<td className="px-4 py-2">
<Badge color={item.status === "active" ? "green" : "gray"}>
{item.status as string}
</Badge>
</td>
<td className="px-4 py-2 text-gray-500">
{Array.isArray(item.key_clauses) ? (item.key_clauses as unknown[]).length : 0}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { TrendingUp, Plus } from "lucide-react";
import { listInterventions } from "@/lib/api-v2";
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function AlphaPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listInterventions()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer
title="Alpha 归因"
description="干预事件 → 指标变化 → 估值影响 → 回报贡献"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} />
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无干预记录" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{item.intervention_type as string}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<span className="text-xs text-gray-400">{item.executed_at as string}</span>
</div>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description as string}</p>}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { Users } from "lucide-react";
import { listBoardMeetings } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function BoardPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listBoardMeetings()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="董事会管理" description="AI 会前材料摘要 → 决议追踪 → 提问清单生成">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无董事会会议" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.title as string}</td>
<td className="px-4 py-2 text-gray-600">{item.meeting_at as string}</td>
<td className="px-4 py-2">
<Badge color={item.status === "completed" ? "green" : item.status === "in_progress" ? "amber" : "blue"}>
{item.status as string}
</Badge>
</td>
<td className="px-4 py-2 text-gray-500">
{Array.isArray(item.resolutions) ? (item.resolutions as unknown[]).length : 0}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,336 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { TabNav, TabPanel, type TabId } from "@/components/workbench/TabNav";
import { HealthRadar, DIMENSIONS_14, DIMENSIONS_14_DETAIL, HealthDimensionDetail } from "@/components/health/HealthRadar";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { apiFetch } from "@/lib/api";
import { AlertTriangle, FileText, ClipboardList, ScrollText, Network, DollarSign, Activity, Users, Bot } from "lucide-react";
/** 企业详情工作台 — 多 Tab 集成页面。 */
export default function WorkbenchPage() {
const params = useParams();
const companyId = params.id as string;
const [activeTab, setActiveTab] = useState<TabId>("overview");
const [detail, setDetail] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!companyId) return;
apiFetch(`/companies/${companyId}/detail`)
.then((res) => {
setDetail(res.data);
setLoading(false);
})
.catch(() => setLoading(false));
}, [companyId]);
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!detail) {
return <EmptyState title="未找到企业信息" />;
}
const { company, health_score, recent_reports, open_risks, recent_weak_signals, active_agreements, recent_board_meetings } = detail;
return (
<div className="flex flex-col gap-4">
{/* 企业头部信息 */}
<div className="flex items-center justify-between rounded-lg border border-[var(--border)] bg-white p-4">
<div>
<h1 className="text-xl font-bold">{company.name}</h1>
<p className="text-sm text-muted-foreground">
{company.industry || "未知行业"} · {company.stage || "未知阶段"}
</p>
</div>
{health_score && (
<div className="text-right">
<div className="text-2xl font-bold text-[var(--investor-primary)]">
{health_score.total_score.toFixed(0)}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
)}
</div>
{/* Tab 导航 + 内容 */}
<div className="flex flex-col gap-4 md:flex-row">
<TabNav active={activeTab} onChange={setActiveTab} />
<TabPanel>
{activeTab === "overview" && <OverviewTab detail={detail} />}
{activeTab === "financial" && <FinancialTab detail={detail} />}
{activeTab === "operational" && <OperationalTab detail={detail} />}
{activeTab === "org" && <OrgTab detail={detail} />}
{activeTab === "ai" && <AITab detail={detail} />}
{activeTab === "risk" && <RiskTab risks={open_risks} signals={recent_weak_signals} />}
{activeTab === "reports" && <ReportsTab reports={recent_reports} />}
{activeTab === "board" && <BoardTab meetings={recent_board_meetings} />}
{activeTab === "agreements" && <AgreementsTab agreements={active_agreements} />}
{activeTab === "synergy" && <SynergyTab companyId={company.id} />}
</TabPanel>
</div>
</div>
);
}
/** 概览 Tab — 健康度雷达图 + 维度详情。 */
function OverviewTab({ detail }: { detail: any }) {
const { health_score } = detail;
if (!health_score) {
return <EmptyState title="暂无健康度评分数据" />;
}
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="mb-2 text-sm font-medium"></h3>
<HealthRadar scores={health_score} dimensions={DIMENSIONS_14} size={320} />
</div>
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="mb-2 text-sm font-medium"></h3>
<HealthDimensionDetail scores={health_score} dimensions={DIMENSIONS_14_DETAIL} />
</div>
</div>
);
}
/** 财务 Tab。 */
function FinancialTab({ detail }: { detail: any }) {
const { latest_financial, financial_data_count } = detail;
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<DollarSign size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<p className="mt-2 text-sm text-muted-foreground"> {financial_data_count} </p>
{latest_financial && (
<pre className="mt-2 overflow-auto rounded-md bg-muted p-3 text-xs">
{JSON.stringify(latest_financial, null, 2)}
</pre>
)}
</div>
</div>
);
}
/** 经营 Tab。 */
function OperationalTab({ detail }: { detail: any }) {
const { recent_reports } = detail;
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Activity size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{recent_reports && recent_reports.length > 0 ? (
<div className="mt-2 space-y-2">
{recent_reports.map((r: any) => (
<div key={r.id} className="flex items-center justify-between text-sm">
<span>{r.period_year}{r.period_month}</span>
<span className="text-muted-foreground">{r.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无经营数据" />
)}
</div>
</div>
);
}
/** 组织 Tab。 */
function OrgTab({ detail }: { detail: any }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Users size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<EmptyState title="组织数据待月报结构化后展示" />
</div>
);
}
/** AI+专项 Tab。 */
function AITab({ detail }: { detail: any }) {
const { health_score } = detail;
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Bot size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium">AI+ </h3>
</div>
{health_score && (
<div className="mt-2 grid grid-cols-2 gap-2">
<ScoreCard label="AI 商业化" score={health_score.ai_commercial_score} />
<ScoreCard label="AI 成本" score={health_score.ai_cost_score} />
<ScoreCard label="AI 模型产品" score={health_score.ai_model_product_score} />
<ScoreCard label="数据合规" score={health_score.data_compliance_score} />
</div>
)}
</div>
</div>
);
}
/** 风险 Tab。 */
function RiskTab({ risks, signals }: { risks: any[]; signals: any[] }) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-amber-500" />
<h3 className="text-sm font-medium"> ({risks?.length || 0})</h3>
</div>
{risks && risks.length > 0 ? (
<div className="mt-2 space-y-2">
{risks.map((r: any) => (
<div key={r.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div>
<span className="text-sm font-medium">{r.title}</span>
<span className="ml-2 text-xs text-muted-foreground">{r.type}</span>
</div>
<span className={`rounded px-2 py-0.5 text-xs ${
r.severity === "critical" ? "bg-rose-100 text-rose-700" :
r.severity === "high" ? "bg-amber-100 text-amber-700" :
"bg-muted text-muted-foreground"
}`}>{r.severity}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无未解决风险" />
)}
</div>
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="text-sm font-medium"> ({signals?.length || 0})</h3>
{signals && signals.length > 0 ? (
<div className="mt-2 space-y-2">
{signals.map((s: any) => (
<div key={s.id} className="flex items-center justify-between text-sm">
<span className="truncate">{s.content}</span>
<span className="ml-2 shrink-0 text-xs text-muted-foreground">
: {(s.confidence * 100).toFixed(0)}%
</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无弱信号" />
)}
</div>
</div>
);
}
/** 月报 Tab。 */
function ReportsTab({ reports }: { reports: any[] }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{reports && reports.length > 0 ? (
<div className="mt-2 space-y-2">
{reports.map((r: any) => (
<div key={r.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div>
<span className="text-sm font-medium">{r.period_year}{r.period_month}</span>
{r.ai_summary && <p className="mt-1 text-xs text-muted-foreground line-clamp-2">{r.ai_summary}</p>}
</div>
<span className="rounded bg-muted px-2 py-0.5 text-xs">{r.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无月报" />
)}
</div>
);
}
/** 董事会 Tab。 */
function BoardTab({ meetings }: { meetings: any[] }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<ClipboardList size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{meetings && meetings.length > 0 ? (
<div className="mt-2 space-y-2">
{meetings.map((m: any) => (
<div key={m.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<span className="text-sm font-medium">{m.title}</span>
<span className="rounded bg-muted px-2 py-0.5 text-xs">{m.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无董事会会议" />
)}
</div>
);
}
/** 协议 Tab。 */
function AgreementsTab({ agreements }: { agreements: any[] }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<ScrollText size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{agreements && agreements.length > 0 ? (
<div className="mt-2 space-y-2">
{agreements.map((a: any) => (
<div key={a.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<span className="text-sm font-medium">{a.title}</span>
<span className="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">{a.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无活跃协议" />
)}
</div>
);
}
/** 协同 Tab。 */
function SynergyTab({ companyId }: { companyId: string }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Network size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<EmptyState title="请前往协同中心查看" />
</div>
);
}
/** 评分小卡片。 */
function ScoreCard({ label, score }: { label: string; score: number | null | undefined }) {
if (score == null) return null;
const color = score >= 75 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
return (
<div className="rounded-md border border-[var(--border)] px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`text-lg font-bold ${color}`}>{score.toFixed(0)}</div>
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { PlanCard } from "@/components/customer/PlanCard";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Sparkles, Plus } from "lucide-react";
/** 客户增长引擎页面。 */
export default function CustomerGrowthPage() {
const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [companyContext, setCompanyContext] = useState("");
const [lpResources, setLpResources] = useState("");
useEffect(() => {
loadPlans();
}, []);
async function loadPlans() {
try {
const res = await apiFetch<any[]>("/customer-plans");
setPlans((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleGenerate() {
if (!companyContext.trim()) return;
setGenerating(true);
try {
const res = await apiFetch<any>("/customer-plans/generate", {
method: "POST",
body: JSON.stringify({ company_context: companyContext, lp_resources: lpResources }),
});
if (res.data) {
setPlans((prev) => [{ ...res.data, id: Date.now().toString(), execution_status: "planned" }, ...prev]);
}
} catch {
// ignore
} finally {
setGenerating(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">AI LP + Portfolio </p>
</div>
{/* 生成方案区域 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述企业产品、目标市场、当前客户情况..."
value={companyContext}
onChange={(e) => setCompanyContext(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">LP </label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={2}
placeholder="描述可利用的 LP 资源、行业人脉等..."
value={lpResources}
onChange={(e) => setLpResources(e.target.value)}
/>
</div>
<button
onClick={handleGenerate}
disabled={generating || !companyContext.trim()}
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
<Plus size={16} />
{generating ? "生成中..." : "生成方案"}
</button>
</div>
</div>
{/* 方案列表 */}
<div>
<h2 className="mb-3 text-sm font-medium"> ({plans.length})</h2>
{plans.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
) : (
<EmptyState title="暂无客户获取方案" description="使用 AI 生成第一个方案" />
)}
</div>
</div>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { FileText, TrendingUp, AlertTriangle, Sparkles } from "lucide-react";
/** 客户成功运营页面 — QBR + 扩展机会 + 流失风险。 */
export default function CustomerSuccessPage() {
const [churnRisks, setChurnRisks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [qbrData, setQbrData] = useState("");
const [qbrResult, setQbrResult] = useState<any>(null);
const [generating, setGenerating] = useState(false);
const [expansionData, setExpansionData] = useState("");
const [expansionResult, setExpansionResult] = useState<any>(null);
const [expanding, setExpanding] = useState(false);
useEffect(() => {
loadChurnRisks();
}, []);
async function loadChurnRisks() {
try {
const res = await apiFetch<any[]>("/customer-success/churn-risk");
setChurnRisks((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleQBR() {
if (!qbrData.trim()) return;
setGenerating(true);
try {
const res = await apiFetch<any>("/customer-success/qbr", {
method: "POST",
body: JSON.stringify({ company_id: "", quarter_data: qbrData }),
});
setQbrResult(res.data);
} catch {
// ignore
} finally {
setGenerating(false);
}
}
async function handleExpansion() {
if (!expansionData.trim()) return;
setExpanding(true);
try {
const res = await apiFetch<any>("/customer-success/expansion", {
method: "POST",
body: JSON.stringify({ company_data: expansionData }),
});
setExpansionResult(res.data);
} catch {
// ignore
} finally {
setExpanding(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">QBR · · </p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* QBR 生成 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">QBR </h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="输入季度数据:营收、客户数、流失率、关键进展..."
value={qbrData}
onChange={(e) => setQbrData(e.target.value)}
/>
<button
onClick={handleQBR}
disabled={generating || !qbrData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中..." : "生成 QBR"}
</button>
{qbrResult && (
<div className="mt-3 space-y-2 text-sm">
{qbrResult.summary && <div><span className="text-muted-foreground"></span>{qbrResult.summary}</div>}
{qbrResult.key_metrics && Array.isArray(qbrResult.key_metrics) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{qbrResult.key_metrics.map((m: string, i: number) => <li key={i}>{m}</li>)}
</ul>
</div>
)}
{qbrResult.next_quarter_recommendations && Array.isArray(qbrResult.next_quarter_recommendations) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{qbrResult.next_quarter_recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
)}
</div>
{/* 扩展机会 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<TrendingUp size={18} className="text-emerald-600" />
<h2 className="text-sm font-medium"></h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述企业当前产品、市场、客户群..."
value={expansionData}
onChange={(e) => setExpansionData(e.target.value)}
/>
<button
onClick={handleExpansion}
disabled={expanding || !expansionData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{expanding ? "分析中..." : "识别扩展机会"}
</button>
{expansionResult && Array.isArray(expansionResult) && (
<div className="mt-3 space-y-2">
{expansionResult.map((opp: any, i: number) => (
<div key={i} className="rounded-md border border-[var(--border)] px-3 py-2 text-sm">
<div className="font-medium">{opp.type || opp.title || `机会 ${i + 1}`}</div>
{opp.description && <div className="mt-1 text-xs text-muted-foreground">{opp.description}</div>}
</div>
))}
</div>
)}
</div>
</div>
{/* 流失风险预警 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-amber-500" />
<h2 className="text-sm font-medium"> ({churnRisks.length})</h2>
</div>
{churnRisks.length > 0 ? (
<div className="mt-2 space-y-2">
{churnRisks.map((risk, i) => (
<div key={i} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div>
<span className="text-sm font-medium">{risk.company_name || risk.company_id || "企业"}</span>
{risk.signals && <p className="text-xs text-muted-foreground">{risk.signals}</p>}
</div>
<span className={`rounded px-2 py-0.5 text-xs ${
risk.risk_level === "high" ? "bg-rose-100 text-rose-700" :
risk.risk_level === "medium" ? "bg-amber-100 text-amber-700" :
"bg-muted text-muted-foreground"
}`}>{risk.risk_level || "low"}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无流失风险预警" />
)}
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { getDashboardSummary, type DashboardSummary } from "@/lib/dashboard";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
import { HealthHeatmap, HealthTrends } from "@/components/dashboard/HealthHeatmap";
/**
* 投资人端 — 驾驶舱首页。
@@ -150,6 +151,17 @@ export default function InvestorDashboardPage() {
<EmptyState title="暂无评分数据" description="健康度评分将在月报提交后自动计算" />
)}
</div>
{/* T2.12 热力图 + 趋势对比 */}
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold"></h2>
<HealthHeatmap />
</div>
<div className="rounded-lg border bg-white p-5 shadow-sm">
<HealthTrends />
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { Bot, Sparkles } from "lucide-react";
import { listDigitalTwins, buildDigitalTwin, simulateTwin } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function DigitalTwinsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyData, setCompanyData] = useState("");
const [scenario, setScenario] = useState("");
const [simResult, setSimResult] = useState<Record<string, any> | null>(null);
useEffect(() => {
listDigitalTwins("")
.then(() => setItems([]))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
const handleBuild = async () => {
if (!companyData.trim()) {
toast.error("请输入企业数据");
return;
}
try {
const resp = await buildDigitalTwin(companyData);
toast.success("数字孪生模型已构建");
setItems([resp.data as Record<string, any>, ...items]);
} catch {
toast.error("构建失败");
}
};
const handleSimulate = async () => {
if (!scenario.trim()) {
toast.error("请输入场景描述");
return;
}
try {
const resp = await simulateTwin({}, scenario);
setSimResult(resp.data as Record<string, any>);
} catch {
toast.error("模拟失败");
}
};
return (
<PageContainer title="数字孪生" description="企业模型 + 场景模拟 + 精度追踪">
<Card>
<h3 className="font-medium text-gray-900"></h3>
<textarea
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
placeholder="输入企业数据..."
className="mt-2 w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
<button
onClick={handleBuild}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} />
</button>
</Card>
<Card>
<h3 className="font-medium text-gray-900"></h3>
<input
type="text"
value={scenario}
onChange={(e) => setScenario(e.target.value)}
placeholder="输入场景描述(如:融资 5000 万)"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<button
onClick={handleSimulate}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} />
</button>
{simResult && (
<div className="mt-3 text-sm text-gray-600">
<p>{simResult.projected_outcome as string}</p>
{simResult.confidence != null && (
<p className="mt-1"><Badge color="blue">{((simResult.confidence as number) * 100).toFixed(0)}%</Badge></p>
)}
</div>
)}
</Card>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无数字孪生模型" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900"></h3>
{item.accuracy_score != null && (
<Badge color={(item.accuracy_score as number) > 0.7 ? "green" : "amber"}>
{((item.accuracy_score as number) * 100).toFixed(0)}%
</Badge>
)}
</div>
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { listMajorEvents, listInquiries } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function EventsPage() {
const [events, setEvents] = useState<Record<string, any>[]>([]);
const [inquiries, setInquiries] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [tab, setTab] = useState<"events" | "inquiries">("events");
useEffect(() => {
Promise.all([listMajorEvents(), listInquiries()])
.then(([e, i]) => {
setEvents((e.data as Record<string, any>[]) ?? []);
setInquiries((i.data as Record<string, any>[]) ?? []);
})
.catch(() => {
setEvents([]);
setInquiries([]);
})
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="重大事项 & 追问清单" description="AI 从月报/弱信号中自动识别重大事项 + 生成追问清单">
<div className="flex gap-2 border-b border-gray-200">
<button
onClick={() => setTab("events")}
className={`px-4 py-2 text-sm font-medium ${tab === "events" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
</button>
<button
onClick={() => setTab("inquiries")}
className={`px-4 py-2 text-sm font-medium ${tab === "inquiries" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
</button>
</div>
{isLoading ? (
<LoadingSpinner />
) : tab === "events" ? (
events.length === 0 ? (
<EmptyState description="暂无重大事项" />
) : (
<div className="space-y-3">
{events.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{item.event_type as string}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<Badge color={item.severity === "critical" ? "red" : item.severity === "high" ? "amber" : "gray"}>
{item.severity as string}
</Badge>
</div>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description as string}</p>}
</div>
))}
</div>
)
) : (
inquiries.length === 0 ? (
<EmptyState description="暂无追问清单" />
) : (
<div className="space-y-3">
{inquiries.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900"></h3>
<Badge color={item.status === "answered" ? "green" : item.status === "closed" ? "gray" : "amber"}>
{item.status as string}
</Badge>
</div>
<div className="mt-2 space-y-1">
{Array.isArray(item.questions) && (item.questions as unknown[]).map((q, qi) => (
<p key={qi} className="text-sm text-gray-600"> {String(q)}</p>
))}
</div>
</div>
))}
</div>
)
)}
</PageContainer>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { TrendingUp, Sparkles } from "lucide-react";
import { listExitPredictions, predictExit } from "@/lib/api-v2";
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function ExitSignalsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyData, setCompanyData] = useState("");
const [predicting, setPredicting] = useState(false);
useEffect(() => {
listExitPredictions()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
const handlePredict = async () => {
if (!companyData.trim()) {
toast.error("请输入企业数据");
return;
}
setPredicting(true);
try {
const resp = await predictExit(companyData);
const result = resp.data as Record<string, any>;
if (result) {
setItems([result, ...items]);
toast.success("预测完成");
}
} catch {
toast.error("预测失败");
} finally {
setPredicting(false);
}
};
return (
<PageContainer title="退出信号" description="AI 计算退出路径 + 时机窗口 + 期望收益对比">
<Card>
<textarea
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
placeholder="输入企业数据(财务/经营/市场)..."
className="w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
<button
onClick={handlePredict}
disabled={predicting}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} /> {predicting ? "预测中..." : "AI 退出预测"}
</button>
</Card>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无退出预测" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.exit_path && <Badge color="blue">{item.exit_path as string}</Badge>}
<span className="text-sm text-gray-900">{item.expected_return as string ?? "-"}</span>
</div>
{item.confidence != null && (
<Badge color={(item.confidence as number) > 0.6 ? "green" : "amber"}>
{((item.confidence as number) * 100).toFixed(0)}%
</Badge>
)}
</div>
{item.recommendation && <p className="mt-2 text-sm text-gray-600">{item.recommendation as string}</p>}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { Receipt } from "lucide-react";
import { listFinancialData, validateFinancial } from "@/lib/api-v2";
import { PageContainer, Card, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
/**
* 投资人端 — 财务数据校验页面。
*/
export default function FinancialPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyId, setCompanyId] = useState("");
useEffect(() => {
if (!companyId) {
setIsLoading(false);
return;
}
listFinancialData(companyId)
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, [companyId]);
return (
<PageContainer title="财务数据校验" description="AI 交叉验证不同来源数据一致性,检测报表内部逻辑矛盾">
<div className="flex items-center gap-2">
<input
type="text"
placeholder="输入企业 ID"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
</div>
{isLoading ? (
<LoadingSpinner />
) : !companyId ? (
<EmptyState title="请输入企业 ID" description="输入企业 ID 后查看财务数据" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.length === 0 ? (
<TableEmpty colSpan={4} />
) : (
items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">
{item.period_year}-{String(item.period_month).padStart(2, "0")}
</td>
<td className="px-4 py-2 text-gray-600">{item.statement_type as string}</td>
<td className="px-4 py-2">
<Badge color={
(item.credibility_score as number) >= 80 ? "green" :
(item.credibility_score as number) >= 50 ? "amber" : "red"
}>
{item.credibility_score as number}
</Badge>
</td>
<td className="px-4 py-2 text-gray-500">{item.source as string}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { Lightbulb, Sparkles } from "lucide-react";
import { discoverInnovation } from "@/lib/api-v2";
import { PageContainer, Card } from "@/components/shared/PageContainer";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function InnovationPage() {
const [capabilities, setCapabilities] = useState("");
const [results, setResults] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleDiscover = async () => {
if (!capabilities.trim()) {
toast.error("请输入 Portfolio 企业能力描述");
return;
}
setIsLoading(true);
try {
const resp = await discoverInnovation(capabilities);
setResults((resp.data as Record<string, any>[]) ?? []);
} catch {
toast.error("分析失败");
} finally {
setIsLoading(false);
}
};
return (
<PageContainer title="组合创新实验室" description="AI 分析企业能力组合 → 发现联合产品方案">
<Card>
<textarea
value={capabilities}
onChange={(e) => setCapabilities(e.target.value)}
placeholder="输入 Portfolio 内企业能力描述..."
className="w-full rounded-md border border-gray-300 p-3 text-sm"
rows={4}
/>
<button
onClick={handleDiscover}
disabled={isLoading}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} /> {isLoading ? "分析中..." : "发现创新机会"}
</button>
</Card>
{results.length > 0 && (
<div className="space-y-3">
{results.map((item, i) => (
<Card key={i}>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
<p className="mt-1 text-sm text-gray-600">{item.combined_capability as string}</p>
{item.market_analysis && <p className="mt-1 text-xs text-gray-400">{item.market_analysis as string}</p>}
</Card>
))}
</div>
)}
{results.length === 0 && !isLoading && capabilities && (
<EmptyState description="点击按钮开始分析" />
)}
</PageContainer>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { Network, Sparkles } from "lucide-react";
import { buildKnowledgeGraph, matchBestStrategy } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { toast } from "sonner";
export default function KnowledgeGraphPage() {
const [experiences, setExperiences] = useState("");
const [graph, setGraph] = useState<Record<string, any> | null>(null);
const [companyProfile, setCompanyProfile] = useState("");
const [matchResult, setMatchResult] = useState<Record<string, any> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleBuild = async () => {
if (!experiences.trim()) {
toast.error("请输入管理经验描述");
return;
}
setIsLoading(true);
try {
const resp = await buildKnowledgeGraph(experiences);
setGraph(resp.data as Record<string, any>);
toast.success("知识图谱已构建");
} catch {
toast.error("构建失败");
} finally {
setIsLoading(false);
}
};
const handleMatch = async () => {
if (!companyProfile.trim() || !graph) {
toast.error("请先构建知识图谱并输入企业画像");
return;
}
try {
const resp = await matchBestStrategy(companyProfile, graph);
setMatchResult(resp.data as Record<string, any>);
} catch {
toast.error("匹配失败");
}
};
return (
<PageContainer title="知识图谱" description="企业特征 + 管理动作 + 环境上下文 → 结果 → 回报影响">
<Card>
<h3 className="font-medium text-gray-900"></h3>
<textarea
value={experiences}
onChange={(e) => setExperiences(e.target.value)}
placeholder="输入历史管理经验描述..."
className="mt-2 w-full rounded-md border border-gray-300 p-3 text-sm"
rows={4}
/>
<button
onClick={handleBuild}
disabled={isLoading}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} />
</button>
{graph && (
<div className="mt-3 text-sm">
<p>{Array.isArray(graph.nodes) ? (graph.nodes as unknown[]).length : 0}</p>
<p>{Array.isArray(graph.relations) ? (graph.relations as unknown[]).length : 0}</p>
</div>
)}
</Card>
{graph && (
<Card>
<h3 className="font-medium text-gray-900"></h3>
<textarea
value={companyProfile}
onChange={(e) => setCompanyProfile(e.target.value)}
placeholder="输入新企业画像..."
className="mt-2 w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
<button
onClick={handleMatch}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} />
</button>
{matchResult && (
<div className="mt-3 space-y-2 text-sm text-gray-600">
<p>{matchResult.recommended_strategy as string}</p>
{matchResult.match_confidence != null && (
<p><Badge color="blue">{((matchResult.match_confidence as number) * 100).toFixed(0)}%</Badge></p>
)}
</div>
)}
</Card>
)}
</PageContainer>
);
}
+75 -18
View File
@@ -1,16 +1,66 @@
/** 投资人端布局 — B 端专业风格:左 Sidebar + 灰色内容区 + AI Copilot。 */
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
import {
LayoutDashboard, Building2, FileText, AlertTriangle, Settings,
Receipt, ScrollText, Users, Lightbulb, Target, GitBranch,
TrendingUp, Network, ShieldCheck, Bot, BookOpen, BarChart3,
} from "lucide-react";
import Link from "next/link";
import { CopilotWidget } from "@/components/shared/CopilotWidget";
import { RiskToastNotifier } from "@/components/shared/RiskToastNotifier";
import { AuthGuard } from "@/components/shared/AuthGuard";
const navItems = [
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
{ href: "/companies", label: "企业档案", icon: Building2 },
{ href: "/reports", label: "月报管理", icon: FileText },
{ href: "/risks", label: "风险工作台", icon: AlertTriangle },
{ href: "/settings", label: "设置", icon: Settings },
const navSections = [
{
title: "核心功能",
items: [
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
{ href: "/companies", label: "企业档案", icon: Building2 },
{ href: "/reports", label: "月报管理", icon: FileText },
{ href: "/risks", label: "风险工作台", icon: AlertTriangle },
],
},
{
title: "投后管理",
items: [
{ href: "/financial", label: "财务校验", icon: Receipt },
{ href: "/agreements", label: "协议监控", icon: ScrollText },
{ href: "/board", label: "董事会", icon: Users },
{ href: "/weak-signals", label: "弱信号", icon: Lightbulb },
{ href: "/sentinels", label: "决策前哨", icon: Target },
{ href: "/events", label: "重大事项", icon: AlertTriangle },
],
},
{
title: "增值服务",
items: [
{ href: "/synergies", label: "协同中心", icon: Network },
{ href: "/innovation", label: "组合创新", icon: Lightbulb },
{ href: "/talents", label: "人才引力场", icon: Users },
{ href: "/okrs", label: "OKR", icon: Target },
{ href: "/milestones", label: "里程碑", icon: GitBranch },
{ href: "/tasks", label: "任务看板", icon: BarChart3 },
],
},
{
title: "高级分析",
items: [
{ href: "/alpha", label: "Alpha 归因", icon: TrendingUp },
{ href: "/exit-signals", label: "退出信号", icon: TrendingUp },
{ href: "/portfolio", label: "组合管理", icon: BarChart3 },
{ href: "/digital-twins", label: "数字孪生", icon: Bot },
{ href: "/knowledge-graph", label: "知识图谱", icon: Network },
{ href: "/aars", label: "AAR 复盘", icon: BookOpen },
{ href: "/pre-mortems", label: "Pre-mortem", icon: ShieldCheck },
{ href: "/agents", label: "Agent 监控", icon: Bot },
],
},
{
title: "系统",
items: [
{ href: "/settings", label: "设置", icon: Settings },
],
},
];
export default function InvestorLayout({ children }: { children: React.ReactNode }) {
@@ -19,16 +69,21 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
{/* 左侧 Sidebar */}
<aside className="sticky top-0 hidden h-screen w-52 shrink-0 bg-[var(--investor-sidebar-bg)] text-white md:block">
<div className="flex h-14 items-center px-4 font-bold">AIPortPilot</div>
<nav className="flex flex-col gap-1 px-3 py-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
<item.icon size={16} aria-hidden="true" />
{item.label}
</Link>
<nav className="flex flex-col gap-1 overflow-y-auto px-3 py-2">
{navSections.map((section) => (
<div key={section.title} className="mb-2">
<div className="px-3 py-1 text-xs font-medium text-white/40">{section.title}</div>
{section.items.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
<item.icon size={16} aria-hidden="true" />
{item.label}
</Link>
))}
</div>
))}
</nav>
</aside>
@@ -39,7 +94,9 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
<header className="sticky top-0 z-10 flex h-14 items-center bg-[var(--investor-sidebar-bg)] px-4 text-white md:hidden">
<span className="font-bold">AIPortPilot</span>
</header>
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
<div className="container mx-auto max-w-7xl px-4 py-6">
<AuthGuard>{children}</AuthGuard>
</div>
</main>
<CopilotWidget />
<RiskToastNotifier />
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { GitBranch } from "lucide-react";
import { listMilestones } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function MilestonesPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyId, setCompanyId] = useState("");
useEffect(() => {
if (!companyId) {
setIsLoading(false);
return;
}
listMilestones(companyId)
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, [companyId]);
return (
<PageContainer title="里程碑树" description="分支路径管理 + 环境变化时 AI 建议路径切换">
<div className="flex items-center gap-2">
<input
type="text"
placeholder="输入企业 ID"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
</div>
{isLoading ? (
<LoadingSpinner />
) : !companyId ? (
<EmptyState title="请输入企业 ID" description="输入企业 ID 后查看里程碑树" />
) : items.length === 0 ? (
<EmptyState description="暂无里程碑" />
) : (
<div className="space-y-2">
{items.map((item, i) => (
<div key={i} className={`rounded-lg border p-3 ${item.is_current ? "border-gray-900 bg-gray-50" : "border-gray-200 bg-white"}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.is_current && <Badge color="blue"></Badge>}
<span className="font-medium text-gray-900">{item.name as string}</span>
</div>
<Badge color={item.status === "completed" ? "green" : item.status === "in_progress" ? "amber" : "gray"}>
{item.status as string}
</Badge>
</div>
{item.description && <p className="mt-1 text-sm text-gray-600">{item.description as string}</p>}
{item.target_date && <p className="mt-1 text-xs text-gray-400">{item.target_date as string}</p>}
</div>
))}
</div>
)}
</PageContainer>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Lightbulb, Sparkles, Check, X } from "lucide-react";
/** 行为助推引擎页面。 */
export default function NudgesPage() {
const [nudges, setNudges] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [context, setContext] = useState("");
const [generating, setGenerating] = useState(false);
const [strategy, setStrategy] = useState<any>(null);
useEffect(() => {
loadNudges();
}, []);
async function loadNudges() {
try {
const res = await apiFetch<any[]>("/nudges");
setNudges((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleSelect() {
if (!context.trim()) return;
setGenerating(true);
try {
const res = await apiFetch<any>("/nudges/select", {
method: "POST",
body: JSON.stringify({ context }),
});
setStrategy(res.data);
} catch {
// ignore
} finally {
setGenerating(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">AI </p>
</div>
{/* AI 助推策略选择 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述当前场景和需要助推的行为..."
value={context}
onChange={(e) => setContext(e.target.value)}
/>
<button
onClick={handleSelect}
disabled={generating || !context.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "分析中..." : "选择策略"}
</button>
{strategy && (
<div className="mt-3 space-y-2 text-sm">
{strategy.strategy && <div><span className="text-muted-foreground"></span>{strategy.strategy}</div>}
{strategy.message && <div><span className="text-muted-foreground"></span>{strategy.message}</div>}
{strategy.expected_effect && <div><span className="text-muted-foreground"></span>{strategy.expected_effect}</div>}
</div>
)}
</div>
{/* 助推记录列表 */}
<div>
<h2 className="mb-3 text-sm font-medium"> ({nudges.length})</h2>
{nudges.length > 0 ? (
<div className="space-y-2">
{nudges.map((n) => (
<div key={n.id} className="flex items-center justify-between rounded-lg border border-[var(--border)] bg-white p-3">
<div className="flex items-center gap-3">
<Lightbulb size={16} className="text-amber-500" />
<div>
<div className="text-sm font-medium">{n.nudge_type}</div>
<div className="text-xs text-muted-foreground">{n.message}</div>
</div>
</div>
<div className="flex items-center gap-2">
{n.accepted ? (
<span className="flex items-center gap-1 text-xs text-emerald-600"><Check size={12} /></span>
) : (
<span className="flex items-center gap-1 text-xs text-muted-foreground"><X size={12} /></span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState title="暂无助推记录" />
)}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { Target, Plus } from "lucide-react";
import { listOKRs } from "@/lib/api-v2";
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function OKRsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listOKRs()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer
title="OKR 管理"
description="投资人与创始人共同制定 OKR + AI 对齐度评分 + 偏差预警"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} /> OKR
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无 OKR" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<div>
<Badge color="blue">{item.quarter as string}</Badge>
<h3 className="mt-1 font-medium text-gray-900">{item.objective as string}</h3>
</div>
<div className="flex items-center gap-2">
{item.alignment_score != null && (
<Badge color={(item.alignment_score as number) >= 75 ? "green" : "amber"}>
{item.alignment_score}
</Badge>
)}
<Badge color={item.status === "active" ? "green" : "gray"}>{item.status as string}</Badge>
</div>
</div>
{Array.isArray(item.key_results) && (
<div className="mt-2 space-y-1">
{(item.key_results as Record<string, any>[]).map((kr, ki) => (
<div key={ki} className="flex items-center gap-2 text-sm text-gray-600">
<span className="h-1.5 w-1.5 rounded-full bg-gray-400" />
{String(kr.title ?? kr.objective ?? kr)}
{kr.progress != null && <span className="text-gray-400">({kr.progress}%)</span>}
</div>
))}
</div>
)}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Users, Sparkles, Circle } from "lucide-react";
/** Peer Learning Circles 页面。 */
export default function PeerCirclesPage() {
const [circles, setCircles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [foundersContext, setFoundersContext] = useState("");
const [matching, setMatching] = useState(false);
const [matchResult, setMatchResult] = useState<any>(null);
useEffect(() => {
loadCircles();
}, []);
async function loadCircles() {
try {
const res = await apiFetch<any[]>("/peer-circles");
setCircles((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleMatch() {
if (!foundersContext.trim()) return;
setMatching(true);
try {
const res = await apiFetch<any>("/peer-circles/match", {
method: "POST",
body: JSON.stringify({ founders_context: foundersContext }),
});
setMatchResult(res.data);
} catch {
// ignore
} finally {
setMatching(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Peer Learning Circles</h1>
<p className="mt-1 text-sm text-muted-foreground"> AI + + </p>
</div>
{/* AI 匹配创始人 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述参与匹配的创始人背景、行业、阶段..."
value={foundersContext}
onChange={(e) => setFoundersContext(e.target.value)}
/>
<button
onClick={handleMatch}
disabled={matching || !foundersContext.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{matching ? "匹配中..." : "开始匹配"}
</button>
{matchResult && (
<div className="mt-3 space-y-2 text-sm">
{matchResult.topic && <div><span className="text-muted-foreground"></span>{matchResult.topic}</div>}
{matchResult.matched_founders && Array.isArray(matchResult.matched_founders) && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 flex flex-wrap gap-1">
{matchResult.matched_founders.map((f: any, i: number) => (
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
{typeof f === "string" ? f : f.name || `创始人${i + 1}`}
</span>
))}
</div>
</div>
)}
{matchResult.discussion_framework && (
<div><span className="text-muted-foreground"></span>{matchResult.discussion_framework}</div>
)}
</div>
)}
</div>
{/* Circle 列表 */}
<div>
<h2 className="mb-3 text-sm font-medium">Learning Circles ({circles.length})</h2>
{circles.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{circles.map((c) => (
<div key={c.id} className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Circle size={16} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium">{c.topic}</h3>
</div>
<span className={`rounded px-2 py-0.5 text-xs ${
c.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-muted text-muted-foreground"
}`}>{c.status}</span>
</div>
{c.description && <p className="mt-2 text-xs text-muted-foreground">{c.description}</p>}
{c.members && Array.isArray(c.members) && (
<div className="mt-2 flex items-center gap-1">
<Users size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{c.members.length} </span>
</div>
)}
{c.conclusions && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"></span>{c.conclusions}
</div>
)}
{c.action_commitments && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground"></span>{c.action_commitments}
</div>
)}
</div>
))}
</div>
) : (
<EmptyState title="暂无 Learning Circle" description="使用 AI 匹配创建第一个 Circle" />
)}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { BarChart3, Sparkles } from "lucide-react";
import { rebalancePortfolio, runMonteCarlo } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { toast } from "sonner";
export default function PortfolioPage() {
const [rebalanceResult, setRebalanceResult] = useState<Record<string, any> | null>(null);
const [mcResult, setMcResult] = useState<Record<string, any> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleRebalance = async () => {
setIsLoading(true);
try {
const resp = await rebalancePortfolio([]);
setRebalanceResult(resp.data as Record<string, any>);
} catch {
toast.error("分析失败");
} finally {
setIsLoading(false);
}
};
const handleMonteCarlo = async () => {
setIsLoading(true);
try {
const resp = await runMonteCarlo([0.15, 0.22, 0.08, 0.35, 0.12]);
setMcResult(resp.data as Record<string, any>);
} catch {
toast.error("模拟失败");
} finally {
setIsLoading(false);
}
};
return (
<PageContainer title="组合管理" description="边际回报率计算 + 组合再平衡 + Monte Carlo 模拟">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<h3 className="font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500">AI </p>
<button
onClick={handleRebalance}
disabled={isLoading}
className="mt-3 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} />
</button>
{rebalanceResult && (
<div className="mt-3 space-y-2 text-sm">
{rebalanceResult.irr_impact != null && (
<p>IRR <Badge color="green">+{rebalanceResult.irr_impact}%</Badge></p>
)}
{rebalanceResult.dpi_impact != null && (
<p>DPI <Badge color="green">+{rebalanceResult.dpi_impact}</Badge></p>
)}
</div>
)}
</Card>
<Card>
<h3 className="font-medium text-gray-900">Monte Carlo </h3>
<p className="mt-1 text-sm text-gray-500">10000 IRR/DPI </p>
<button
onClick={handleMonteCarlo}
disabled={isLoading}
className="mt-3 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} />
</button>
{mcResult && (
<div className="mt-3 space-y-2 text-sm">
<p>P5: <Badge color="red">{mcResult.percentile_p5}</Badge></p>
<p>P50: <Badge color="amber">{mcResult.percentile_p50}</Badge></p>
<p>P95: <Badge color="green">{mcResult.percentile_p95}</Badge></p>
</div>
)}
</Card>
</div>
</PageContainer>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useState } from "react";
import { ShieldCheck, Sparkles } from "lucide-react";
import { listPreMortems, runPreMortem, listRedTeams, runRedTeam } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function PreMortemsPage() {
const [tab, setTab] = useState<"pre-mortem" | "red-team">("pre-mortem");
const [preMortems, setPreMortems] = useState<Record<string, any>[]>([]);
const [redTeams, setRedTeams] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [input, setInput] = useState("");
const [perspective, setPerspective] = useState("competitor");
useEffect(() => {
Promise.all([listPreMortems(), listRedTeams()])
.then(([pm, rt]) => {
setPreMortems((pm.data as Record<string, any>[]) ?? []);
setRedTeams((rt.data as Record<string, any>[]) ?? []);
})
.catch(() => {
setPreMortems([]);
setRedTeams([]);
})
.finally(() => setIsLoading(false));
}, []);
const handleRun = async () => {
if (!input.trim()) {
toast.error("请输入内容");
return;
}
try {
if (tab === "pre-mortem") {
const resp = await runPreMortem(input);
setPreMortems([resp.data as Record<string, any>, ...preMortems]);
toast.success("Pre-mortem 分析完成");
} else {
const resp = await runRedTeam(input, perspective);
setRedTeams([resp.data as Record<string, any>, ...redTeams]);
toast.success("Red Team 分析完成");
}
} catch {
toast.error("分析失败");
}
};
return (
<PageContainer title="Pre-mortem & Red Team" description="AI 失败路径推演 + 对抗分析">
<div className="flex gap-2 border-b border-gray-200">
<button
onClick={() => setTab("pre-mortem")}
className={`px-4 py-2 text-sm font-medium ${tab === "pre-mortem" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
Pre-mortem
</button>
<button
onClick={() => setTab("red-team")}
className={`px-4 py-2 text-sm font-medium ${tab === "red-team" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
Red Team
</button>
</div>
<Card>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={tab === "pre-mortem" ? "输入决策上下文..." : "输入企业上下文..."}
className="w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
{tab === "red-team" && (
<select
value={perspective}
onChange={(e) => setPerspective(e.target.value)}
className="mt-2 rounded-md border border-gray-300 px-3 py-1.5 text-sm"
>
<option value="competitor"></option>
<option value="pessimistic_investor"></option>
<option value="devils_advocate"></option>
</select>
)}
<button
onClick={handleRun}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} /> {tab === "pre-mortem" ? "失败推演" : "对抗分析"}
</button>
</Card>
{isLoading ? (
<LoadingSpinner />
) : tab === "pre-mortem" ? (
preMortems.length === 0 ? <EmptyState description="暂无 Pre-mortem 记录" /> : (
<div className="space-y-3">
{preMortems.map((item, i) => (
<Card key={i}>
<h3 className="font-medium text-gray-900">{item.decision_context as string}</h3>
{Array.isArray(item.failure_paths) && (
<div className="mt-2 space-y-1">
{(item.failure_paths as Record<string, any>[]).map((fp, fi) => (
<p key={fi} className="text-xs text-gray-500"> {String(fp.path ?? fp)}</p>
))}
</div>
)}
</Card>
))}
</div>
)
) : (
redTeams.length === 0 ? <EmptyState description="暂无 Red Team 记录" /> : (
<div className="space-y-3">
{redTeams.map((item, i) => (
<Card key={i}>
<div className="flex items-center gap-2">
<Badge color="red">{item.perspective as string}</Badge>
</div>
<p className="mt-2 text-sm text-gray-600">{item.analysis as string}</p>
</Card>
))}
</div>
)
)}
</PageContainer>
);
}
@@ -0,0 +1,159 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Sparkles, Grid3x3 } from "lucide-react";
/** AI 产品竞争力诊断页面。 */
export default function ProductDiagnosticsPage() {
const [diagnostics, setDiagnostics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [productInfo, setProductInfo] = useState("");
const [competitorInfo, setCompetitorInfo] = useState("");
const [diagnosing, setDiagnosing] = useState(false);
const [result, setResult] = useState<any>(null);
useEffect(() => {
loadDiagnostics();
}, []);
async function loadDiagnostics() {
try {
const res = await apiFetch<any[]>("/product-diagnostics");
setDiagnostics((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleDiagnose() {
if (!productInfo.trim()) return;
setDiagnosing(true);
try {
const res = await apiFetch<any>("/product-diagnostics/diagnose", {
method: "POST",
body: JSON.stringify({ product_info: productInfo, competitor_info: competitorInfo }),
});
setResult(res.data);
} catch {
// ignore
} finally {
setDiagnosing(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">AI </h1>
<p className="mt-1 text-sm text-muted-foreground">AI 线</p>
</div>
{/* AI 诊断区域 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述产品功能、目标用户、核心卖点..."
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={2}
placeholder="列出主要竞品及其特点..."
value={competitorInfo}
onChange={(e) => setCompetitorInfo(e.target.value)}
/>
</div>
<button
onClick={handleDiagnose}
disabled={diagnosing || !productInfo.trim()}
className="rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{diagnosing ? "诊断中..." : "开始诊断"}
</button>
</div>
</div>
{/* 诊断结果热力图 */}
{result && (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Grid3x3 size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium"></h2>
</div>
{result.dimensions && Array.isArray(result.dimensions) && (
<div className="mt-3 grid grid-cols-2 gap-2 md:grid-cols-3">
{result.dimensions.map((dim: any, i: number) => {
const score = dim.score ?? 0;
const color = score >= 75 ? "bg-emerald-200" : score >= 50 ? "bg-amber-100" : "bg-rose-200";
return (
<div key={i} className={`rounded-md ${color} p-2 text-center`}>
<div className="text-xs font-medium">{dim.name}</div>
<div className="text-lg font-bold">{score.toFixed(0)}</div>
</div>
);
})}
</div>
)}
{result.roadmap_suggestions && Array.isArray(result.roadmap_suggestions) && (
<div className="mt-3">
<div className="text-xs text-muted-foreground">线</div>
<ul className="ml-4 list-disc text-sm">
{result.roadmap_suggestions.map((s: string, i: number) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
</div>
)}
{/* 历史诊断列表 */}
<div>
<h2 className="mb-3 text-sm font-medium"> ({diagnostics.length})</h2>
{diagnostics.length > 0 ? (
<div className="space-y-2">
{diagnostics.map((d) => (
<div key={d.id} className="rounded-lg border border-[var(--border)] bg-white p-3">
<h3 className="text-sm font-medium">{d.product_name || "未命名产品"}</h3>
{d.dimensions && Array.isArray(d.dimensions) && (
<div className="mt-1 flex flex-wrap gap-1">
{d.dimensions.map((dim: any, i: number) => (
<span key={i} className="rounded bg-muted px-2 py-0.5 text-xs">
{dim.name}: {dim.score?.toFixed(0) ?? "N/A"}
</span>
))}
</div>
)}
</div>
))}
</div>
) : (
<EmptyState title="暂无诊断记录" />
)}
</div>
</div>
);
}
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { ReportTemplateSelector, ReportPreview } from "@/components/report/ReportPreview";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
/** 投后报告模板选择页。 */
export default function ReportTemplatesPage() {
const [report, setReport] = useState<any>(null);
const [generating, setGenerating] = useState(false);
async function handleGenerate(type: string, data: string) {
setGenerating(true);
try {
// 使用 copilot chat 生成报告
const resp = await fetch("/api/v1/copilot/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({
message: `请基于以下数据生成${type === "quarterly" ? "季度" : "年度"}投后管理报告:\n\n${data}`,
context: { type: `${type}_report` },
}),
});
const reader = resp.body?.getReader();
if (reader) {
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += new TextDecoder().decode(value);
}
setReport({ executive_summary: text, company_name: "测试企业" });
}
} catch {
// ignore
} finally {
setGenerating(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground"> AI PDF</p>
</div>
<ReportTemplateSelector onGenerate={handleGenerate} />
{generating && (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
)}
{report && !generating && (
<ReportPreview report={report} companyName="投后管理报告" />
)}
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import { Target } from "lucide-react";
import { listDecisionSentinels } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function SentinelsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listDecisionSentinels()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="决策前哨" description="AI 识别关键决策岔路口 → 场景分析 → 提前预警">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无决策前哨记录" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{item.decision_type as string}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<Badge color={item.status === "acted" ? "green" : item.status === "analyzed" ? "amber" : "gray"}>
{item.status as string}
</Badge>
</div>
{item.description && (
<p className="mt-2 text-sm text-gray-600">{item.description as string}</p>
)}
</div>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { Shield, Key, Lock, Bell } from "lucide-react";
/** 投资人设置页 — 修改密码/2FA/API Key 管理。 */
export default function SettingsPage() {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const [message, setMessage] = useState("");
async function handleChangePassword() {
if (newPassword !== confirmPassword) {
setMessage("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setMessage("新密码至少 8 位");
return;
}
setMessage("密码修改功能待后端接口接入");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground"> · · API Key </p>
</div>
{/* 修改密码 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Lock size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium"></h2>
</div>
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
type="password"
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
type="password"
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
type="password"
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{message && <p className="text-xs text-amber-600">{message}</p>}
<button
onClick={handleChangePassword}
className="rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white"
>
</button>
</div>
</div>
{/* 双因素认证 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Shield size={18} className="text-emerald-600" />
<h2 className="text-sm font-medium"> (2FA)</h2>
</div>
<div className="mt-3 flex items-center justify-between">
<p className="text-sm text-muted-foreground">使 TOTP Google Authenticator</p>
<button
onClick={() => setTwoFactorEnabled(!twoFactorEnabled)}
className={`relative h-6 w-11 rounded-full transition-colors ${twoFactorEnabled ? "bg-emerald-500" : "bg-muted"}`}
role="switch"
aria-checked={twoFactorEnabled}
aria-label="双因素认证开关"
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${twoFactorEnabled ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
</div>
{twoFactorEnabled && (
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
2FA 线
</div>
)}
</div>
{/* API Key 管理 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Key size={18} className="text-amber-600" />
<h2 className="text-sm font-medium">API Key </h2>
</div>
<p className="mt-2 text-sm text-muted-foreground">
API Key Key
</p>
<div className="mt-3 rounded-md border border-[var(--border)] p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-mono">sk-****...****</span>
<span className="rounded bg-muted px-2 py-0.5 text-xs"></span>
</div>
</div>
<button className="mt-2 rounded-md border border-[var(--border)] px-4 py-2 text-sm hover:bg-muted">
Key
</button>
</div>
{/* 通知设置 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Bell size={18} className="text-blue-600" />
<h2 className="text-sm font-medium"></h2>
</div>
<div className="mt-3 space-y-2 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" defaultChecked className="rounded" />
<span></span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" defaultChecked className="rounded" />
<span></span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" className="rounded" />
<span></span>
</label>
</div>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { Network, Plus } from "lucide-react";
import { listSynergies, authorizeSynergy } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
const SYNERGY_TYPE_LABELS: Record<string, string> = {
customer: "客户协同",
talent: "人才协同",
funding: "融资协同",
supply_chain: "供应链协同",
tech: "技术协同",
};
export default function SynergiesPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = () => {
listSynergies()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
};
useEffect(() => { load(); }, []);
const handleAuthorize = async (id: string) => {
try {
await authorizeSynergy(id);
toast.success("授权成功");
load();
} catch {
toast.error("授权失败");
}
};
return (
<PageContainer title="协同中心" description="Portfolio 内部协同匹配 → 双方授权 → 效果追踪">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无协同机会" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{SYNERGY_TYPE_LABELS[item.type as string] ?? item.type}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<div className="flex items-center gap-2">
<Badge color={item.status === "completed" ? "green" : item.status === "authorized" ? "blue" : "gray"}>
{item.status as string}
</Badge>
{item.authorized === false && (
<button
onClick={() => handleAuthorize(item.id as string)}
className="rounded-md bg-gray-900 px-2 py-1 text-xs text-white hover:bg-gray-700"
>
</button>
)}
</div>
</div>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description as string}</p>}
{item.match_reason && <p className="mt-1 text-xs text-gray-400">{item.match_reason as string}</p>}
</div>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { Users } from "lucide-react";
import { listTalents } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function TalentsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listTalents()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="人才引力场" description="人才流动预测 + 主动推荐 + 9-Box 矩阵">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无人才数据" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500">9-Box</th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.name as string}</td>
<td className="px-4 py-2 text-gray-600">{item.current_role as string}</td>
<td className="px-4 py-2 text-gray-600">{item.performance_rating as string ?? "-"}</td>
<td className="px-4 py-2 text-gray-600">{item.potential_rating as string ?? "-"}</td>
<td className="px-4 py-2"><Badge color="blue">{item.nine_box as string ?? "-"}</Badge></td>
<td className="px-4 py-2"><Badge color={item.status === "active" ? "green" : "gray"}>{item.status as string}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { BarChart3, Plus } from "lucide-react";
import { listTasks, updateTask } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
const STATUS_LABELS: Record<string, string> = {
todo: "待办",
in_progress: "进行中",
done: "已完成",
cancelled: "已取消",
};
const PRIORITY_COLORS: Record<string, "red" | "amber" | "gray" | "blue"> = {
urgent: "red",
high: "amber",
medium: "gray",
low: "blue",
};
export default function TasksPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = () => {
listTasks()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
};
useEffect(() => { load(); }, []);
const handleStatusChange = async (id: string, status: string) => {
try {
await updateTask(id, { status });
toast.success("状态已更新");
load();
} catch {
toast.error("更新失败");
}
};
const columns = ["todo", "in_progress", "done"];
return (
<PageContainer
title="任务看板"
description="投后任务管理 — 来源:风险/月报/协同/手动"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} />
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无任务" />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{columns.map((col) => (
<div key={col} className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<h3 className="mb-2 text-sm font-medium text-gray-700">{STATUS_LABELS[col]}</h3>
<div className="space-y-2">
{items.filter((i) => i.status === col).map((item, i) => (
<div key={i} className="rounded-md border border-gray-200 bg-white p-3">
<div className="flex items-start justify-between">
<span className="text-sm font-medium text-gray-900">{item.title as string}</span>
<Badge color={PRIORITY_COLORS[item.priority as string] ?? "gray"}>
{item.priority as string}
</Badge>
</div>
{item.description && <p className="mt-1 text-xs text-gray-500">{item.description as string}</p>}
<select
value={item.status as string}
onChange={(e) => handleStatusChange(item.id as string, e.target.value)}
className="mt-2 w-full rounded border border-gray-200 px-2 py-1 text-xs"
>
{Object.entries(STATUS_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
))}
</div>
</div>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { Lightbulb } from "lucide-react";
import { listWeakSignals } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
const SIGNAL_TYPE_LABELS: Record<string, string> = {
technical: "技术",
sentiment: "情绪",
org: "组织",
market: "市场",
};
export default function WeakSignalsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listWeakSignals()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="弱信号监控" description="技术/情绪/组织/市场四类弱信号采集与关联分析">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无弱信号" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2">
<Badge color="blue">{SIGNAL_TYPE_LABELS[item.signal_type as string] ?? item.signal_type}</Badge>
</td>
<td className="px-4 py-2 text-gray-900 max-w-xs truncate">{item.content as string}</td>
<td className="px-4 py-2 text-gray-600">{((item.confidence as number) * 100).toFixed(0)}%</td>
<td className="px-4 py-2">
{item.risk_probability ? (
<Badge color={(item.risk_probability as number) > 0.6 ? "red" : (item.risk_probability as number) > 0.3 ? "amber" : "green"}>
{((item.risk_probability as number) * 100).toFixed(0)}%
</Badge>
) : "-"}
</td>
<td className="px-4 py-2">
<Badge color={item.status === "alerted" ? "red" : item.status === "correlated" ? "amber" : "gray"}>
{item.status as string}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}