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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,140 @@
|
||||
/** Admin 端首页 — 占位页面。 */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminOverview, listTenants, listUsers, listAuditLogs } from "@/lib/api-v2";
|
||||
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
|
||||
/**
|
||||
* Admin 管理后台首页 — 系统概览 + 租户/用户/审计标签页。
|
||||
*/
|
||||
export default function AdminHomePage() {
|
||||
const [overview, setOverview] = useState<Record<string, any> | null>(null);
|
||||
const [tenants, setTenants] = useState<Record<string, any>[]>([]);
|
||||
const [users, setUsers] = useState<Record<string, any>[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<Record<string, any>[]>([]);
|
||||
const [tab, setTab] = useState<"overview" | "tenants" | "users" | "audit">("overview");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminOverview(), listTenants(), listUsers(), listAuditLogs()])
|
||||
.then(([ov, t, u, a]) => {
|
||||
setOverview(ov.data as Record<string, any>);
|
||||
setTenants((t.data as Record<string, any>[]) ?? []);
|
||||
setUsers((u.data as Record<string, any>[]) ?? []);
|
||||
setAuditLogs((a.data as Record<string, any>[]) ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
setOverview(null);
|
||||
setTenants([]);
|
||||
setUsers([]);
|
||||
setAuditLogs([]);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">管理后台</h1>
|
||||
<EmptyState title="暂无数据" description="租户管理与权限配置功能开发中" />
|
||||
</div>
|
||||
<PageContainer title="管理后台" description="系统概览 · 租户管理 · 用户管理 · 审计日志">
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{[
|
||||
{ key: "overview", label: "概览" },
|
||||
{ key: "tenants", label: "租户" },
|
||||
{ key: "users", label: "用户" },
|
||||
{ key: "audit", label: "审计日志" },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key as typeof tab)}
|
||||
className={`px-4 py-2 text-sm font-medium ${tab === t.key ? "border-b-2 border-amber-400 text-gray-900" : "text-gray-500"}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "overview" && overview && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">租户数量</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900">{String(overview.tenant_count ?? 0)}</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">用户数量</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900">{String(overview.user_count ?? 0)}</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">租户列表</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{Array.isArray(overview.tenants) && (overview.tenants as Record<string, any>[]).map((t, i) => (
|
||||
<p key={i} className="text-sm text-gray-700">{String(t.name ?? "")}</p>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "tenants" && (
|
||||
tenants.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></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{tenants.map((t, i) => (
|
||||
<tr key={i}><td className="px-4 py-2 text-gray-900">{String(t.name ?? "")}</td><td className="px-4 py-2 text-gray-500">{String(t.created_at ?? "")}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "users" && (
|
||||
users.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">
|
||||
{users.map((u, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-2 text-gray-900">{String(u.email ?? "")}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{String(u.name ?? "")}</td>
|
||||
<td className="px-4 py-2"><Badge color="blue">{String(u.role ?? "")}</Badge></td>
|
||||
<td className="px-4 py-2"><Badge color={u.is_active ? "green" : "red"}>{u.is_active ? "活跃" : "禁用"}</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "audit" && (
|
||||
auditLogs.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></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{auditLogs.map((log, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-2 text-gray-900">{String(log.action ?? "")}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{String(log.target_type ?? "")}: {String(log.target_id ?? "")}</td>
|
||||
<td className="px-4 py-2 text-gray-500">{String(log.created_at ?? "")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/** 创始人端 AI 副驾驶页面 — 占位。 */
|
||||
/** 创始人端 AI 副驾驶页面 — 融资规划 + 组织诊断 + 投资人沟通准备。 */
|
||||
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { FinancingPlanner, OrgDiagnostic, InvestorComm } from "@/components/founder/CopilotTools";
|
||||
|
||||
export default function FounderCopilotPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">AI 副驾驶</h1>
|
||||
<EmptyState title="AI 副驾驶" description="对话功能开发中" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">AI 副驾驶</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">融资规划 · 组织诊断 · 投资人沟通准备</p>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<FinancingPlanner />
|
||||
<OrgDiagnostic />
|
||||
<InvestorComm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,100 @@
|
||||
/** 创始人端经营概览 — 占位页面。 */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { founderOverview, founderHealth } from "@/lib/api-v2";
|
||||
import { Card, Badge } from "@/components/shared/PageContainer";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
|
||||
/**
|
||||
* 创始人端经营概览 — 企业信息 + 健康度 + 最新月报。
|
||||
*/
|
||||
export default function FounderHomePage() {
|
||||
const [overview, setOverview] = useState<Record<string, any> | null>(null);
|
||||
const [health, setHealth] = useState<Record<string, any> | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([founderOverview(), founderHealth()])
|
||||
.then(([ov, h]) => {
|
||||
setOverview(ov.data as Record<string, any>);
|
||||
setHealth(h.data as Record<string, any>);
|
||||
})
|
||||
.catch(() => {
|
||||
setOverview(null);
|
||||
setHealth(null);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
const company = overview?.company as Record<string, any> | undefined;
|
||||
const healthScore = overview?.health_score as Record<string, any> | undefined;
|
||||
const latestReport = overview?.latest_report as Record<string, any> | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">企业经营概览</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">关键指标与健康度一览</p>
|
||||
</div>
|
||||
<LoadingSpinner className="py-20" size={32} />
|
||||
|
||||
{!company && <EmptyState description="暂无关联企业" />}
|
||||
|
||||
{company && (
|
||||
<>
|
||||
<Card>
|
||||
<h2 className="font-semibold text-gray-900">{String(company.name ?? "")}</h2>
|
||||
<div className="mt-2 flex items-center gap-3 text-sm text-gray-600">
|
||||
<span>行业:{String(company.industry ?? "")}</span>
|
||||
<span>阶段:{String(company.stage ?? "")}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium text-gray-500">健康度评分</h3>
|
||||
{healthScore ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-3xl font-bold text-gray-900">{String(healthScore.total_score ?? "-")}</span>
|
||||
{healthScore.trend && (
|
||||
<Badge color={String(healthScore.trend) === "up" ? "green" : String(healthScore.trend) === "down" ? "red" : "gray"}>
|
||||
{String(healthScore.trend)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{health && (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p>财务:{String(health.financial_score ?? "-")}</p>
|
||||
<p>运营:{String(health.operational_score ?? "-")}</p>
|
||||
<p>AI 商业化:{String(health.ai_commercial_score ?? "-")}</p>
|
||||
<p>AI 成本:{String(health.ai_cost_score ?? "-")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-gray-400">暂无健康度数据</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium text-gray-500">最新月报</h3>
|
||||
{latestReport ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-lg font-medium text-gray-900">{String(latestReport.period ?? "")}</p>
|
||||
<Badge color={String(latestReport.status) === "submitted" ? "green" : "amber"}>
|
||||
{String(latestReport.status ?? "")}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-gray-400">暂无月报</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Target, ArrowRight, DollarSign, Users, Trophy } from "lucide-react";
|
||||
|
||||
/** 客户获取方案卡片 — 展示切入角度/决策链/定价/竞争分析。 */
|
||||
export function PlanCard({ plan }: { plan: any }) {
|
||||
const statusColors: Record<string, string> = {
|
||||
planned: "bg-blue-100 text-blue-700",
|
||||
executing: "bg-amber-100 text-amber-700",
|
||||
completed: "bg-emerald-100 text-emerald-700",
|
||||
failed: "bg-rose-100 text-rose-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4 shadow-sm">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">{plan.target_customer || "未指定目标客户"}</h3>
|
||||
</div>
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${statusColors[plan.execution_status] || "bg-muted"}`}>
|
||||
{plan.execution_status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 切入角度 */}
|
||||
{plan.entry_angle && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ArrowRight size={12} />
|
||||
<span>切入角度</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{plan.entry_angle}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 定价策略 */}
|
||||
{plan.pricing_strategy && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<DollarSign size={12} />
|
||||
<span>定价策略</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{plan.pricing_strategy}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 决策链 */}
|
||||
{plan.decision_chain && Array.isArray(plan.decision_chain) && plan.decision_chain.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users size={12} />
|
||||
<span>决策链</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{plan.decision_chain.map((node: any, i: number) => (
|
||||
<span key={i} className="rounded-md bg-muted px-2 py-1 text-xs">
|
||||
{node.role || "角色"}
|
||||
{node.influence && ` · ${node.influence}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 竞争分析 */}
|
||||
{plan.competitive_analysis && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Trophy size={12} />
|
||||
<span>竞争分析</span>
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2 text-xs">
|
||||
{plan.competitive_analysis.strengths && (
|
||||
<div>
|
||||
<div className="font-medium text-emerald-600">优势</div>
|
||||
<ul className="ml-3 list-disc">
|
||||
{plan.competitive_analysis.strengths.map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{plan.competitive_analysis.weaknesses && (
|
||||
<div>
|
||||
<div className="font-medium text-rose-600">劣势</div>
|
||||
<ul className="ml-3 list-disc">
|
||||
{plan.competitive_analysis.weaknesses.map((w: string, i: number) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LP 资源 */}
|
||||
{plan.lp_resources && Array.isArray(plan.lp_resources) && plan.lp_resources.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-muted-foreground">可利用 LP 资源</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{plan.lp_resources.map((r: string, i: number) => (
|
||||
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"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 { TrendingUp, TrendingDown, Minus, AlertCircle } from "lucide-react";
|
||||
|
||||
/** 预测趋势图组件 — 含置信区间。 */
|
||||
export function ForecastChart({ companyId }: { companyId?: string }) {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const path = companyId
|
||||
? `/dashboard/forecasts?company_id=${companyId}&months_ahead=3`
|
||||
: "/dashboard/forecasts?months_ahead=3";
|
||||
apiFetch<any>(path)
|
||||
.then((res) => setData(res.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [companyId]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data) return <EmptyState title="暂无预测数据" />;
|
||||
|
||||
const predictions: number[] = data.predictions || [];
|
||||
const trendDir = data.trend_direction || "stable";
|
||||
const TrendIcon = trendDir === "up" ? TrendingUp : trendDir === "down" ? TrendingDown : Minus;
|
||||
const trendColor = trendDir === "up" ? "text-emerald-600" : trendDir === "down" ? "text-rose-600" : "text-muted-foreground";
|
||||
const anomalies = data.anomalies || {};
|
||||
const anomalyKeys = Object.keys(anomalies);
|
||||
|
||||
const width = 500;
|
||||
const height = 200;
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
||||
const allValues = predictions.length > 0 ? predictions : [];
|
||||
const maxVal = 100;
|
||||
const minVal = 0;
|
||||
const yScale = (v: number) => chartH - ((v - minVal) / (maxVal - minVal)) * chartH;
|
||||
const xStep = predictions.length > 1 ? chartW / (predictions.length - 1) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">健康度预测</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">置信度: {(data.confidence * 100).toFixed(0)}%</span>
|
||||
<TrendIcon size={16} className={trendColor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{predictions.length > 0 ? (
|
||||
<svg width={width} height={height} role="img" aria-label="健康度预测趋势图">
|
||||
{[0, 25, 50, 75, 100].map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top + yScale(v)}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + yScale(v)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={v === 0 ? "none" : "2,2"}
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={padding.top + yScale(v) + 4}
|
||||
textAnchor="end"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{v}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* 预测折线 */}
|
||||
<path
|
||||
d={predictions.map((p, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(p)}`).join(" ")}
|
||||
fill="none"
|
||||
stroke="var(--investor-primary)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
|
||||
{/* 数据点 */}
|
||||
{predictions.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={padding.left + i * xStep} cy={padding.top + yScale(p)} r={4} fill="var(--investor-primary)" />
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={padding.top + yScale(p) - 10}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-foreground font-medium"
|
||||
>
|
||||
{p.toFixed(1)}
|
||||
</text>
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={height - padding.bottom + 16}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
+{i + 1}月
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
历史数据不足(需 ≥ 2 期评分),暂无法预测
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 异常检测 */}
|
||||
{anomalyKeys.length > 0 && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2">
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-amber-700">
|
||||
<AlertCircle size={12} />
|
||||
检测到指标异常
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{anomalyKeys.map((key) => (
|
||||
<span key={key} className="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700">
|
||||
{key} (异常点: {anomalies[key].join(", ")})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"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 { DIMENSIONS_14 } from "@/components/health/HealthRadar";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
|
||||
/** 健康度热力图 — 企业 × 维度评分矩阵。 */
|
||||
export function HealthHeatmap() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<any[]>("/dashboard/heatmap")
|
||||
.then((res) => setData((res.data as any[]) || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data.length) return <EmptyState title="暂无热力图数据" />;
|
||||
|
||||
const dims = DIMENSIONS_14;
|
||||
|
||||
/** 根据分数返回背景色。 */
|
||||
const getColor = (score: number | null | undefined) => {
|
||||
if (score == null) return "bg-muted";
|
||||
if (score >= 75) return "bg-emerald-200";
|
||||
if (score >= 60) return "bg-emerald-100";
|
||||
if (score >= 50) return "bg-amber-100";
|
||||
if (score >= 40) return "bg-amber-200";
|
||||
return "bg-rose-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-white px-2 py-1 text-left font-medium">企业</th>
|
||||
<th className="px-2 py-1 text-center font-medium">总分</th>
|
||||
{dims.map((d) => (
|
||||
<th key={d.key} className="px-1 py-1 text-center font-medium" title={d.label}>
|
||||
{d.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={row.company_id} className="border-t border-[var(--border)]">
|
||||
<td className="sticky left-0 z-10 bg-white px-2 py-1 font-medium">
|
||||
{row.company_name}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-center font-bold">
|
||||
{row.total_score != null ? row.total_score.toFixed(0) : "-"}
|
||||
</td>
|
||||
{dims.map((d) => {
|
||||
const val = row.scores?.[d.key];
|
||||
return (
|
||||
<td key={d.key} className="px-1 py-1 text-center">
|
||||
<div
|
||||
className={`mx-auto flex h-8 w-8 items-center justify-center rounded ${getColor(val)}`}
|
||||
title={`${row.company_name} - ${d.label}: ${val ?? "N/A"}`}
|
||||
>
|
||||
{val != null ? val.toFixed(0) : "-"}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 健康度趋势对比图 — 按月汇总评分变化折线图。 */
|
||||
export function HealthTrends({ companyId }: { companyId?: string }) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const path = companyId
|
||||
? `/dashboard/trends?company_id=${companyId}&months=6`
|
||||
: "/dashboard/trends?months=6";
|
||||
apiFetch<any[]>(path)
|
||||
.then((res) => setData((res.data as any[]) || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [companyId]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data.length) return <EmptyState title="暂无趋势数据" />;
|
||||
|
||||
const width = 600;
|
||||
const height = 240;
|
||||
const padding = { top: 20, right: 20, bottom: 40, left: 40 };
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
||||
const periods = data.map((d) => d.period);
|
||||
const scores = data.map((d) => d.avg_score);
|
||||
const maxScore = 100;
|
||||
const minScore = 0;
|
||||
|
||||
const xStep = periods.length > 1 ? chartW / (periods.length - 1) : 0;
|
||||
const yScale = (val: number) => chartH - ((val - minScore) / (maxScore - minScore)) * chartH;
|
||||
|
||||
const linePath = scores
|
||||
.map((s, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(s)}`)
|
||||
.join(" ");
|
||||
|
||||
const prevScore = scores.length > 1 ? scores[scores.length - 2] : null;
|
||||
const latestScore = scores[scores.length - 1];
|
||||
const trendDir = prevScore != null
|
||||
? latestScore > prevScore + 2 ? "up" : latestScore < prevScore - 2 ? "down" : "stable"
|
||||
: "stable";
|
||||
const TrendIcon = trendDir === "up" ? TrendingUp : trendDir === "down" ? TrendingDown : Minus;
|
||||
const trendColor = trendDir === "up" ? "text-emerald-600" : trendDir === "down" ? "text-rose-600" : "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">健康度趋势</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-[var(--investor-primary)]">
|
||||
{latestScore?.toFixed(1)}
|
||||
</span>
|
||||
<TrendIcon size={18} className={trendColor} />
|
||||
</div>
|
||||
</div>
|
||||
<svg width={width} height={height} role="img" aria-label="健康度趋势图">
|
||||
{/* Y 轴刻度 */}
|
||||
{[0, 25, 50, 75, 100].map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top + yScale(v)}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + yScale(v)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={v === 0 ? "none" : "2,2"}
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={padding.top + yScale(v) + 4}
|
||||
textAnchor="end"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{v}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* 折线 */}
|
||||
<path d={linePath} fill="none" stroke="var(--investor-primary)" strokeWidth={2} />
|
||||
{/* 数据点 */}
|
||||
{scores.map((s, i) => (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={padding.left + i * xStep}
|
||||
cy={padding.top + yScale(s)}
|
||||
r={4}
|
||||
fill="var(--investor-primary)"
|
||||
/>
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={padding.top + yScale(s) - 10}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-foreground font-medium"
|
||||
>
|
||||
{s.toFixed(1)}
|
||||
</text>
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={height - padding.bottom + 16}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{periods[i]}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { DollarSign, TrendingUp, Users, MessageSquare } from "lucide-react";
|
||||
|
||||
/** 融资规划组件 — 节奏/估值/投资人画像。 */
|
||||
export function FinancingPlanner() {
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!companyData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/founder/financing-plan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述企业当前融资情况、营收、阶段..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !companyData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "生成中..." : "生成融资规划"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.round && <div><span className="text-muted-foreground">轮次:</span>{result.round}</div>}
|
||||
{result.target_amount && <div><span className="text-muted-foreground">目标金额:</span>{result.target_amount}</div>}
|
||||
{result.valuation_range && <div><span className="text-muted-foreground">估值范围:</span>{result.valuation_range}</div>}
|
||||
{result.timeline && <div><span className="text-muted-foreground">时间节奏:</span>{result.timeline}</div>}
|
||||
{result.target_investors && Array.isArray(result.target_investors) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">目标投资人画像:</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{result.target_investors.map((inv: string, i: number) => (
|
||||
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">{inv}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.key_metrics && Array.isArray(result.key_metrics) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">关键指标:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.key_metrics.map((m: string, i: number) => <li key={i}>{m}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 组织诊断组件 — 团队结构/关键岗位风险/人才缺口。 */
|
||||
export function OrgDiagnostic() {
|
||||
const [teamData, setTeamData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!teamData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/founder/org-diagnostic", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ team_data: teamData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述团队规模、关键岗位、流失情况..."
|
||||
value={teamData}
|
||||
onChange={(e) => setTeamData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !teamData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "诊断中..." : "开始组织诊断"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.structure_assessment && (
|
||||
<div><span className="text-muted-foreground">结构评估:</span>{result.structure_assessment}</div>
|
||||
)}
|
||||
{result.key_role_risks && Array.isArray(result.key_role_risks) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">关键岗位风险:</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{result.key_role_risks.map((r: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-md border border-[var(--border)] px-2 py-1 text-xs">
|
||||
<span>{r.role}</span>
|
||||
<span className={r.severity === "high" ? "text-rose-600" : r.severity === "medium" ? "text-amber-600" : "text-muted-foreground"}>
|
||||
{r.risk}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.talent_gaps && Array.isArray(result.talent_gaps) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">人才缺口:</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{result.talent_gaps.map((g: string, i: number) => (
|
||||
<span key={i} className="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700">{g}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.recommendations && Array.isArray(result.recommendations) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">建议:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 投资人沟通准备组件 — 董事会材料/投资人问答。 */
|
||||
export function InvestorComm() {
|
||||
const [boardContext, setBoardContext] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!boardContext.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/founder/investor-comm-prep", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ board_context: boardContext }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">投资人沟通准备</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述董事会/投资人会议背景、需要汇报的内容..."
|
||||
value={boardContext}
|
||||
onChange={(e) => setBoardContext(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !boardContext.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "准备中..." : "生成沟通材料"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.board_material_outline && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">董事会材料大纲:</span>
|
||||
<p className="mt-1 whitespace-pre-wrap">{result.board_material_outline}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.anticipated_questions && Array.isArray(result.anticipated_questions) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">预期问题与建议回答:</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{result.anticipated_questions.map((q: any, i: number) => (
|
||||
<div key={i} className="rounded-md border border-[var(--border)] px-2 py-1 text-xs">
|
||||
<div className="font-medium">Q: {q.question}</div>
|
||||
<div className="mt-1 text-muted-foreground">A: {q.suggested_answer}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.key_updates && Array.isArray(result.key_updates) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">关键进展:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.key_updates.map((u: string, i: number) => <li key={i}>{u}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{result.asks && Array.isArray(result.asks) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">需要投资人支持的请求:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.asks.map((a: string, i: number) => <li key={i}>{a}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
|
||||
/** 健康度雷达图组件 — 四维评分雷达图。 */
|
||||
/** 健康度雷达图组件 — 支持 4/9/14 维度。 */
|
||||
|
||||
interface HealthRadarProps {
|
||||
scores: {
|
||||
financial: number;
|
||||
operational: number;
|
||||
ai_commercial: number;
|
||||
ai_cost: number;
|
||||
};
|
||||
scores: Record<string, number | null | undefined>;
|
||||
dimensions: { key: string; label: string }[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
financial: "财务",
|
||||
operational: "经营",
|
||||
ai_commercial: "AI 商业化",
|
||||
ai_cost: "AI 成本",
|
||||
};
|
||||
/** 14 维度定义。 */
|
||||
export const DIMENSIONS_14 = [
|
||||
{ key: "financial_score", label: "财务" },
|
||||
{ key: "operational_score", label: "经营" },
|
||||
{ key: "ai_commercial_score", label: "AI商业化" },
|
||||
{ key: "ai_cost_score", label: "AI成本" },
|
||||
{ key: "org_talent_score", label: "组织人才" },
|
||||
{ key: "product_tech_score", label: "产品技术" },
|
||||
{ key: "market_compete_score", label: "市场竞争" },
|
||||
{ key: "governance_score", label: "治理合规" },
|
||||
{ key: "financing_score", label: "融资资本" },
|
||||
{ key: "synergy_score", label: "协同赋能" },
|
||||
{ key: "ai_model_product_score", label: "AI模型产品" },
|
||||
{ key: "data_compliance_score", label: "数据合规" },
|
||||
{ key: "team_tech_score", label: "团队技术" },
|
||||
{ key: "customer_success_score", label: "客户成功" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 健康度雷达图(纯 SVG,无额外依赖)。
|
||||
*/
|
||||
export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
const center = size / 2;
|
||||
const radius = size / 2 - 40;
|
||||
const axes = Object.keys(LABELS);
|
||||
const angleStep = (Math.PI * 2) / axes.length;
|
||||
/** 9 维度定义(T2.9)。 */
|
||||
export const DIMENSIONS_9 = DIMENSIONS_14.slice(0, 9);
|
||||
|
||||
/** 基础 4 维度定义。 */
|
||||
export const DIMENSIONS_4 = DIMENSIONS_14.slice(0, 4);
|
||||
|
||||
/** 健康度雷达图(纯 SVG,支持动态维度数)。 */
|
||||
export function HealthRadar({ scores, dimensions, size = 280 }: HealthRadarProps) {
|
||||
const validDims = dimensions.filter((d) => scores[d.key] != null);
|
||||
|
||||
const { gridLevels, axisLines, dataPoints, labelPositions } = useMemo(() => {
|
||||
const center = size / 2;
|
||||
const radius = size / 2 - 50;
|
||||
const numDims = validDims.length;
|
||||
if (numDims < 3) return { gridLevels: [], axisLines: [], dataPoints: [], labelPositions: [] };
|
||||
|
||||
const angleStep = (Math.PI * 2) / numDims;
|
||||
const levels = [0.25, 0.5, 0.75, 1.0];
|
||||
|
||||
const grid = levels.map((level) =>
|
||||
axes.map((_, i) => {
|
||||
validDims.map((_, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: center + Math.cos(angle) * radius * level,
|
||||
@@ -42,7 +58,7 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
})
|
||||
);
|
||||
|
||||
const axis = axes.map((_, i) => {
|
||||
const axis = validDims.map((_, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: center + Math.cos(angle) * radius,
|
||||
@@ -50,8 +66,8 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const values = [scores.financial, scores.operational, scores.ai_commercial, scores.ai_cost];
|
||||
const points = values.map((val, i) => {
|
||||
const points = validDims.map((dim, i) => {
|
||||
const val = scores[dim.key] ?? 0;
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
const r = (val / 100) * radius;
|
||||
return {
|
||||
@@ -60,23 +76,30 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const labels = axes.map((key, i) => {
|
||||
const labels = validDims.map((dim, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: center + Math.cos(angle) * (radius + 20),
|
||||
y: center + Math.sin(angle) * (radius + 20),
|
||||
label: LABELS[key],
|
||||
x: center + Math.cos(angle) * (radius + 25),
|
||||
y: center + Math.sin(angle) * (radius + 25),
|
||||
label: dim.label,
|
||||
};
|
||||
});
|
||||
|
||||
return { gridLevels: grid, axisLines: axis, dataPoints: points, labelPositions: labels };
|
||||
}, [scores, center, radius, angleStep]);
|
||||
}, [scores, validDims, size]);
|
||||
|
||||
if (validDims.length < 3) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
维度数据不足,无法绘制雷达图
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const polygonPoints = dataPoints.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} role="img" aria-label="健康度雷达图">
|
||||
{/* 网格 */}
|
||||
{gridLevels.map((level, idx) => (
|
||||
<polygon
|
||||
key={idx}
|
||||
@@ -87,13 +110,11 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 轴线 */}
|
||||
{axisLines.map((line, idx) => (
|
||||
<line
|
||||
key={idx}
|
||||
x1={center}
|
||||
y1={center}
|
||||
x1={size / 2}
|
||||
y1={size / 2}
|
||||
x2={line.x}
|
||||
y2={line.y}
|
||||
stroke="currentColor"
|
||||
@@ -101,8 +122,6 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 数据区域 */}
|
||||
<polygon
|
||||
points={polygonPoints}
|
||||
fill="var(--investor-primary)"
|
||||
@@ -110,19 +129,9 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
stroke="var(--investor-primary)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* 数据点 */}
|
||||
{dataPoints.map((p, idx) => (
|
||||
<circle
|
||||
key={idx}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={4}
|
||||
fill="var(--investor-primary)"
|
||||
/>
|
||||
<circle key={idx} cx={p.x} cy={p.y} r={3} fill="var(--investor-primary)" />
|
||||
))}
|
||||
|
||||
{/* 标签 */}
|
||||
{labelPositions.map((lp, idx) => (
|
||||
<text
|
||||
key={idx}
|
||||
@@ -130,7 +139,7 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
y={lp.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-xs fill-muted-foreground"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{lp.label}
|
||||
</text>
|
||||
@@ -138,3 +147,57 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 健康度维度详情面板 — 评分依据/扣分项/建议。 */
|
||||
export function HealthDimensionDetail({
|
||||
scores,
|
||||
dimensions,
|
||||
}: {
|
||||
scores: Record<string, number | null | undefined>;
|
||||
dimensions: { key: string; label: string; description: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{dimensions.map((dim) => {
|
||||
const score = scores[dim.key];
|
||||
if (score == null) return null;
|
||||
const color =
|
||||
score >= 75 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
|
||||
const TrendIcon = score >= 70 ? TrendingUp : score < 40 ? TrendingDown : Minus;
|
||||
return (
|
||||
<div
|
||||
key={dim.key}
|
||||
className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{dim.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{dim.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-lg font-bold ${color}`}>{score.toFixed(0)}</span>
|
||||
<TrendIcon size={16} className={color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 14 维度详情定义(含描述)。 */
|
||||
export const DIMENSIONS_14_DETAIL = [
|
||||
{ key: "financial_score", label: "财务", description: "现金跑道、营收增长、烧钱率" },
|
||||
{ key: "operational_score", label: "经营", description: "团队规模、关键指标达成" },
|
||||
{ key: "ai_commercial_score", label: "AI商业化", description: "AI 相关指标商业化" },
|
||||
{ key: "ai_cost_score", label: "AI成本", description: "AI 推理成本效率" },
|
||||
{ key: "org_talent_score", label: "组织人才", description: "流失率、关键岗位" },
|
||||
{ key: "product_tech_score", label: "产品技术", description: "迭代频率、技术指标" },
|
||||
{ key: "market_compete_score", label: "市场竞争", description: "市场份额、客户增长" },
|
||||
{ key: "governance_score", label: "治理合规", description: "董事会、合规事件" },
|
||||
{ key: "financing_score", label: "融资资本", description: "跑道、融资进度" },
|
||||
{ key: "synergy_score", label: "协同赋能", description: "Portfolio 协同" },
|
||||
{ key: "ai_model_product_score", label: "AI模型产品", description: "模型精度、数据质量" },
|
||||
{ key: "data_compliance_score", label: "数据合规", description: "数据合规审计" },
|
||||
{ key: "team_tech_score", label: "团队技术", description: "技术负责人、专利" },
|
||||
{ key: "customer_success_score", label: "客户成功", description: "留存率、NPS" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { FileText, Printer, Download } from "lucide-react";
|
||||
|
||||
/** 报告预览组件 — 浏览器打印优化。 */
|
||||
export function ReportPreview({ report, companyName }: { report: any; companyName: string }) {
|
||||
if (!report) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between no-print">
|
||||
<h3 className="text-sm font-medium">报告预览</h3>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--border)] px-3 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<Printer size={14} />
|
||||
打印
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-6 print:border-0 print:shadow-none">
|
||||
{/* 封面 */}
|
||||
<div className="mb-6 border-b pb-4 no-print">
|
||||
<h1 className="text-xl font-bold">{companyName} — 投后管理报告</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
生成时间:{new Date().toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 报告内容 */}
|
||||
{report.executive_summary && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">执行摘要</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.executive_summary}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.financial_performance && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">财务表现</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.financial_performance}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.operational_highlights && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">运营亮点</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.operational_highlights}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.risk_assessment && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">风险评估</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.risk_assessment}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.recommendations && Array.isArray(report.recommendations) && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">建议</h2>
|
||||
<ul className="ml-4 list-disc text-sm">
|
||||
{report.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.next_quarter_focus && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">下季度关注重点</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.next_quarter_focus}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 年度报告字段 */}
|
||||
{report.year_in_review && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">年度回顾</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.year_in_review}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.key_achievements && Array.isArray(report.key_achievements) && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">关键成就</h2>
|
||||
<ul className="ml-4 list-disc text-sm">
|
||||
{report.key_achievements.map((a: string, i: number) => <li key={i}>{a}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 报告模板选择页。 */
|
||||
export function ReportTemplateSelector({ onGenerate }: { onGenerate: (type: string, data: string) => void }) {
|
||||
const [template, setTemplate] = useState("quarterly");
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const templates = [
|
||||
{ id: "quarterly", name: "季度报告", desc: "包含财务表现、运营亮点、风险评估、下季度建议" },
|
||||
{ id: "annual", name: "年度报告", desc: "包含年度回顾、关键成就、Alpha 归因、下年度计划" },
|
||||
];
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!companyData.trim()) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
onGenerate(template, companyData);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTemplate(t.id)}
|
||||
className={`rounded-lg border p-4 text-left transition-all ${
|
||||
template === t.id
|
||||
? "border-[var(--investor-primary)] bg-[var(--investor-primary)]/5"
|
||||
: "border-[var(--border)] hover:border-[var(--investor-primary)]/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">{t.name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</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={5}
|
||||
placeholder="输入企业经营数据、关键指标、风险事件等..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || !companyData.trim()}
|
||||
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{generating ? <LoadingSpinner /> : <Download size={14} />}
|
||||
{generating ? "生成中..." : "生成报告"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Clock, TrendingUp } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
|
||||
/**
|
||||
* 月报提交及时性面板 — 延迟热力图 + 数据质量评分卡。
|
||||
*/
|
||||
|
||||
interface TimelinessItem {
|
||||
company_id: string;
|
||||
company_name: string;
|
||||
period_year: number;
|
||||
period_month: number;
|
||||
submitted_at: string;
|
||||
delay_days: number;
|
||||
quality_score: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function TimelinessPanel({ companyId }: { companyId?: string }) {
|
||||
const [items, setItems] = useState<TimelinessItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const path = companyId
|
||||
? `/reports/timeliness?company_id=${companyId}`
|
||||
: "/reports/timeliness";
|
||||
apiFetch<TimelinessItem[]>(path)
|
||||
.then((resp) => setItems(resp.data ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [companyId]);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!items.length) return <EmptyState description="暂无月报提交记录" />;
|
||||
|
||||
const avgDelay = Math.round(
|
||||
items.reduce((s, i) => s + i.delay_days, 0) / items.length,
|
||||
);
|
||||
const avgQuality = Math.round(
|
||||
items.reduce((s, i) => s + i.quality_score, 0) / items.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4" />
|
||||
平均延迟
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900">
|
||||
{avgDelay} <span className="text-sm text-gray-400">天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
平均数据质量
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900">
|
||||
{avgQuality} <span className="text-sm text-gray-400">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-2 text-gray-900">{item.company_name}</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{item.period_year}-{String(item.period_month).padStart(2, "0")}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={
|
||||
item.delay_days === 0
|
||||
? "text-emerald-600"
|
||||
: item.delay_days <= 3
|
||||
? "text-amber-600"
|
||||
: "text-rose-600"
|
||||
}
|
||||
>
|
||||
{item.delay_days} 天
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={
|
||||
item.quality_score >= 80
|
||||
? "text-emerald-600"
|
||||
: item.quality_score >= 50
|
||||
? "text-amber-600"
|
||||
: "text-rose-600"
|
||||
}
|
||||
>
|
||||
{item.quality_score}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{item.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
|
||||
/**
|
||||
* 路由守卫 — 未登录时重定向到登录页。
|
||||
*
|
||||
* 包裹投资人端和创始人端的受保护页面。
|
||||
*/
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const { token, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !token) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isLoading, token, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Upload, File as FileIcon, X, Loader2 } from "lucide-react";
|
||||
|
||||
/** 文件上传组件 — 支持拖拽 + 点击上传。 */
|
||||
export function FileUploader({ onParsed }: { onParsed: (result: any) => void }) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allowedTypes = [".xlsx", ".xls", ".pdf", ".txt", ".md", ".csv"];
|
||||
|
||||
const handleFile = useCallback(async (f: File) => {
|
||||
const ext = f.name.match(/\.[^.]+$/)?.[0]?.toLowerCase() || "";
|
||||
if (!allowedTypes.includes(ext)) {
|
||||
return;
|
||||
}
|
||||
setFile(f);
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", f);
|
||||
const token = localStorage.getItem("token");
|
||||
const resp = await fetch("/api/v1/reports/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
onParsed(data.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [onParsed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 border-dashed border-[var(--border)] p-6 text-center transition-colors"
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
|
||||
}}
|
||||
style={dragging ? { borderColor: "var(--investor-primary)", background: "var(--investor-primary)/5" } : {}}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-[var(--border)] bg-white px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon size={16} className="text-[var(--investor-primary)]" />
|
||||
<span className="text-sm">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{uploading && <Loader2 size={14} className="animate-spin text-muted-foreground" />}
|
||||
<button
|
||||
onClick={() => { setFile(null); onParsed(null); }}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="移除文件"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex flex-col items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Upload size={24} />
|
||||
<span>点击或拖拽文件到此处上传</span>
|
||||
<span className="text-xs">支持 .xlsx .pdf .txt .csv .md(最大 10MB)</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.pdf,.txt,.md,.csv"
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Search, FileText, Loader2 } from "lucide-react";
|
||||
|
||||
/** 语义搜索组件 — 搜索知识库中的月报/报告片段。 */
|
||||
export function KnowledgeSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await apiFetch<any[]>(`/knowledge/search?q=${encodeURIComponent(query)}&top_k=5`);
|
||||
setResults((res.data as any[]) || []);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-md border border-[var(--border)] py-2 pl-9 pr-3 text-sm"
|
||||
placeholder="语义搜索知识库..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading || !query.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searched && !loading && results.length === 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">未找到相关内容</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{results.map((r) => (
|
||||
<div key={r.id} className="rounded-lg border border-[var(--border)] bg-white p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-[var(--investor-primary)]" />
|
||||
<span className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
|
||||
{r.source_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-foreground">{r.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** 通用列表页面布局组件 — 标题 + 描述 + 操作区 + 内容区。 */
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* 通用页面容器。
|
||||
* @param title - 页面标题
|
||||
* @param description - 页面描述
|
||||
* @param actions - 操作按钮区
|
||||
* @param children - 内容区
|
||||
*/
|
||||
export function PageContainer({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单卡片。
|
||||
*/
|
||||
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-gray-200 bg-white p-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签徽章。
|
||||
*/
|
||||
export function Badge({ children, color = "gray" }: { children: ReactNode; color?: "gray" | "green" | "amber" | "red" | "blue" }) {
|
||||
const colors: Record<string, string> = {
|
||||
gray: "bg-gray-100 text-gray-700",
|
||||
green: "bg-emerald-100 text-emerald-700",
|
||||
amber: "bg-amber-100 text-amber-700",
|
||||
red: "bg-rose-100 text-rose-700",
|
||||
blue: "bg-blue-100 text-blue-700",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colors[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空表格行占位。
|
||||
*/
|
||||
export function TableEmpty({ colSpan, message = "暂无数据" }: { colSpan: number; message?: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { AlertTriangle, GitBranch, TrendingDown } from "lucide-react";
|
||||
|
||||
/** 约束点面板 — TOC 约束点识别。 */
|
||||
export function ConstraintPanel() {
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (!companyData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/advanced-analysis/constraints", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">TOC 约束点识别</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述企业当前运营数据和瓶颈..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !companyData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "分析中..." : "识别约束点"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.constraint && <div><span className="text-muted-foreground">核心约束点:</span>{result.constraint}</div>}
|
||||
{result.sensitivity && <div><span className="text-muted-foreground">敏感度:</span>{result.sensitivity}</div>}
|
||||
{result.improvement_space && <div><span className="text-muted-foreground">改善空间:</span>{result.improvement_space}</div>}
|
||||
{result.recommendations && Array.isArray(result.recommendations) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">建议:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** BML 认知追踪组件。 */
|
||||
export function BMLTracker() {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">BML 认知追踪</h3>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2 text-sm text-muted-foreground">
|
||||
<p>假设 → 实验 → 数据 → 结论 循环追踪</p>
|
||||
<p className="text-xs">待月报结构化数据接入后展示</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 鸿沟诊断预警组件。 */
|
||||
export function ChasmAlert() {
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDiagnose() {
|
||||
if (!companyData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/advanced-analysis/chasm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown size={18} className="text-rose-500" />
|
||||
<h3 className="text-sm font-medium">鸿沟诊断预警</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述产品采用情况、客户类型分布..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDiagnose}
|
||||
disabled={loading || !companyData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "诊断中..." : "鸿沟诊断"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.stage && <div><span className="text-muted-foreground">当前阶段:</span>{result.stage}</div>}
|
||||
{result.gap_detected !== undefined && (
|
||||
<div className={`rounded-md px-3 py-1 text-xs ${result.gap_detected ? "bg-rose-100 text-rose-700" : "bg-emerald-100 text-emerald-700"}`}>
|
||||
{result.gap_detected ? "⚠ 检测到鸿沟风险" : "✓ 未检测到鸿沟"}
|
||||
</div>
|
||||
)}
|
||||
{result.crossing_strategy && (
|
||||
<div><span className="text-muted-foreground">跨越策略:</span>{result.crossing_strategy}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
LayoutDashboard, DollarSign, Activity, Users, Bot,
|
||||
AlertTriangle, FileText, ClipboardList, ScrollText, Network,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 工作台 Tab 定义。 */
|
||||
const TABS = [
|
||||
{ id: "overview", label: "概览", icon: LayoutDashboard },
|
||||
{ id: "financial", label: "财务", icon: DollarSign },
|
||||
{ id: "operational", label: "经营", icon: Activity },
|
||||
{ id: "org", label: "组织", icon: Users },
|
||||
{ id: "ai", label: "AI+专项", icon: Bot },
|
||||
{ id: "risk", label: "风险", icon: AlertTriangle },
|
||||
{ id: "reports", label: "月报", icon: FileText },
|
||||
{ id: "board", label: "董事会", icon: ClipboardList },
|
||||
{ id: "agreements", label: "协议", icon: ScrollText },
|
||||
{ id: "synergy", label: "协同", icon: Network },
|
||||
] as const;
|
||||
|
||||
export type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
/** 工作台左侧 Tab 导航组件。 */
|
||||
export function TabNav({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: TabId;
|
||||
onChange: (tab: TabId) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex flex-row gap-1 overflow-x-auto border-b border-[var(--border)] md:w-40 md:flex-col md:overflow-y-auto md:border-b-0 md:border-r">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`flex shrink-0 items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
active === tab.id
|
||||
? "bg-[var(--investor-primary)]/10 font-medium text-[var(--investor-primary)]"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} aria-hidden="true" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** 工作台 Tab 面板容器。 */
|
||||
export function TabPanel({ children }: { children: ReactNode }) {
|
||||
return <div className="flex-1 space-y-4 p-4">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
/** Phase 2-4 通用 API 调用封装。 */
|
||||
|
||||
import { apiFetch, type ApiResponse } from "@/lib/api";
|
||||
|
||||
// ============ Phase 2: 财务数据 ============
|
||||
|
||||
export async function listFinancialData(companyId: string) {
|
||||
return apiFetch(`/financial?company_id=${companyId}`);
|
||||
}
|
||||
|
||||
export async function createFinancialData(data: Record<string, unknown>) {
|
||||
return apiFetch(`/financial`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateFinancial(companyId: string, year: number, month: number) {
|
||||
return apiFetch(`/financial/validate?company_id=${companyId}&period_year=${year}&period_month=${month}`);
|
||||
}
|
||||
|
||||
// ============ Phase 2: 投资协议 ============
|
||||
|
||||
export async function listAgreements(companyId?: string) {
|
||||
const path = companyId ? `/agreements?company_id=${companyId}` : "/agreements";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function createAgreement(data: Record<string, unknown>) {
|
||||
return apiFetch(`/agreements`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getAgreementAlerts(agreementId: string) {
|
||||
return apiFetch(`/agreements/${agreementId}/alerts`);
|
||||
}
|
||||
|
||||
// ============ Phase 2: 董事会 ============
|
||||
|
||||
export async function listBoardMeetings(companyId?: string) {
|
||||
const path = companyId ? `/board?company_id=${companyId}` : "/board";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function createBoardMeeting(data: Record<string, unknown>) {
|
||||
return apiFetch(`/board`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function generateMeetingSummary(meetingId: string, materialsText: string) {
|
||||
return apiFetch(`/board/${meetingId}/generate-summary`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ materials_text: materialsText }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateBoardQuestions(meetingId: string, materialsText: string) {
|
||||
return apiFetch(`/board/${meetingId}/generate-questions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ materials_text: materialsText }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 2: 弱信号 ============
|
||||
|
||||
export async function listWeakSignals(companyId?: string, signalType?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (companyId) params.set("company_id", companyId);
|
||||
if (signalType) params.set("signal_type", signalType);
|
||||
return apiFetch(`/weak-signals${params.toString() ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export async function correlateWeakSignals(signals: unknown[]) {
|
||||
return apiFetch(`/weak-signals/correlate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ signals }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 2: 决策前哨 ============
|
||||
|
||||
export async function listDecisionSentinels(companyId?: string) {
|
||||
const path = companyId ? `/decision-sentinels?company_id=${companyId}` : "/decision-sentinels";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function identifyDecisionPoints(companyContext: string) {
|
||||
return apiFetch(`/decision-sentinels/identify`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_context: companyContext }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function analyzeSentinelScenarios(sentinelId: string, decision: Record<string, unknown>) {
|
||||
return apiFetch(`/decision-sentinels/${sentinelId}/analyze`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 2: 重大事项 + 追问清单 ============
|
||||
|
||||
export async function listMajorEvents(companyId?: string) {
|
||||
const path = companyId ? `/events?company_id=${companyId}` : "/events";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function detectMajorEvents(reportContent: string) {
|
||||
return apiFetch(`/events/detect`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ report_content: reportContent }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listInquiries(companyId?: string) {
|
||||
const path = companyId ? `/inquiries?company_id=${companyId}` : "/inquiries";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function generateInquiryQuestions(reportContent: string, structuredData?: unknown) {
|
||||
return apiFetch(`/inquiries/generate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ report_content: reportContent, structured_data: structuredData }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 2: 画像 ============
|
||||
|
||||
export async function listFirms() {
|
||||
return apiFetch(`/profiles/firms`);
|
||||
}
|
||||
|
||||
export async function listFunds(firmId?: string) {
|
||||
const path = firmId ? `/profiles/funds?firm_id=${firmId}` : "/profiles/funds";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function listManagers(firmId?: string) {
|
||||
const path = firmId ? `/profiles/managers?firm_id=${firmId}` : "/profiles/managers";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
// ============ Phase 3: 协同 ============
|
||||
|
||||
export async function listSynergies(companyId?: string) {
|
||||
const path = companyId ? `/synergies?company_id=${companyId}` : "/synergies";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function matchSynergies(companyAContext: string, portfolioContext: string) {
|
||||
return apiFetch(`/synergies/match`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_a_context: companyAContext, portfolio_context: portfolioContext }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function authorizeSynergy(synergyId: string) {
|
||||
return apiFetch(`/synergies/${synergyId}/authorize`, { method: "PUT" });
|
||||
}
|
||||
|
||||
// ============ Phase 3: 创新 ============
|
||||
|
||||
export async function discoverInnovation(portfolioCapabilities: string) {
|
||||
return apiFetch(`/innovation/discover`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ portfolio_capabilities: portfolioCapabilities }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: 人才 ============
|
||||
|
||||
export async function listTalents() {
|
||||
return apiFetch(`/talents`);
|
||||
}
|
||||
|
||||
export async function predictTalentFlow(talentData: string) {
|
||||
return apiFetch(`/talents/predict-flow`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ talent_data: talentData }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function recommendTalent(companyNeed: string, talentPool: string) {
|
||||
return apiFetch(`/talents/recommend`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_need: companyNeed, talent_pool: talentPool }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: OKR ============
|
||||
|
||||
export async function listOKRs(companyId?: string) {
|
||||
const path = companyId ? `/okrs?company_id=${companyId}` : "/okrs";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function createOKR(data: Record<string, unknown>) {
|
||||
return apiFetch(`/okrs`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function trackOKR(okrId: string, keyResults: unknown[]) {
|
||||
return apiFetch(`/okrs/${okrId}/track`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key_results: keyResults }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: 助推 ============
|
||||
|
||||
export async function listNudges(companyId?: string) {
|
||||
const path = companyId ? `/nudges?company_id=${companyId}` : "/nudges";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function selectNudge(context: string) {
|
||||
return apiFetch(`/nudges/select`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ context }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: Peer Circles ============
|
||||
|
||||
export async function listPeerCircles() {
|
||||
return apiFetch(`/peer-circles`);
|
||||
}
|
||||
|
||||
export async function matchPeerCircle(foundersContext: string) {
|
||||
return apiFetch(`/peer-circles/match`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ founders_context: foundersContext }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: 产品诊断 ============
|
||||
|
||||
export async function listProductDiagnostics(companyId?: string) {
|
||||
const path = companyId ? `/product-diagnostics?company_id=${companyId}` : "/product-diagnostics";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function diagnoseProduct(productInfo: string, competitorInfo: string) {
|
||||
return apiFetch(`/product-diagnostics/diagnose`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ product_info: productInfo, competitor_info: competitorInfo }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: 里程碑 ============
|
||||
|
||||
export async function listMilestones(companyId: string) {
|
||||
return apiFetch(`/milestones?company_id=${companyId}`);
|
||||
}
|
||||
|
||||
export async function suggestMilestoneSwitch(milestoneContext: string, envChanges: string) {
|
||||
return apiFetch(`/milestones/suggest-switch`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ milestone_context: milestoneContext, env_changes: envChanges }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: 高级分析 ============
|
||||
|
||||
export async function analyzeConstraints(companyData: string) {
|
||||
return apiFetch(`/advanced-analysis/constraints`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function analyzeChasm(companyData: string) {
|
||||
return apiFetch(`/advanced-analysis/chasm`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 3: 任务 + 评论 ============
|
||||
|
||||
export async function listTasks(companyId?: string, status?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (companyId) params.set("company_id", companyId);
|
||||
if (status) params.set("status", status);
|
||||
return apiFetch(`/tasks${params.toString() ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export async function createTask(data: Record<string, unknown>) {
|
||||
return apiFetch(`/tasks`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateTask(taskId: string, data: Record<string, unknown>) {
|
||||
return apiFetch(`/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function listComments(targetType: string, targetId: string) {
|
||||
return apiFetch(`/comments?target_type=${targetType}&target_id=${targetId}`);
|
||||
}
|
||||
|
||||
export async function createComment(data: Record<string, unknown>) {
|
||||
return apiFetch(`/comments`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
// ============ Phase 3: 客户成功 ============
|
||||
|
||||
export async function generateQBR(companyId: string, quarterData: string) {
|
||||
return apiFetch(`/customer-success/qbr`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_id: companyId, quarter_data: quarterData }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function identifyExpansion(companyData: string) {
|
||||
return apiFetch(`/customer-success/expansion`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChurnRisk() {
|
||||
return apiFetch(`/customer-success/churn-risk`);
|
||||
}
|
||||
|
||||
// ============ Phase 4: Alpha 归因 ============
|
||||
|
||||
export async function listInterventions(companyId?: string) {
|
||||
const path = companyId ? `/alpha?company_id=${companyId}` : "/alpha";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function createIntervention(data: Record<string, unknown>) {
|
||||
return apiFetch(`/alpha`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function attributeAlpha(interventionId: string, intervention: unknown, metricChanges: unknown) {
|
||||
return apiFetch(`/alpha/${interventionId}/attribute`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ intervention, metric_changes: metricChanges }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: 退出预测 ============
|
||||
|
||||
export async function listExitPredictions(companyId?: string) {
|
||||
const path = companyId ? `/exit-predictions?company_id=${companyId}` : "/exit-predictions";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function predictExit(companyData: string) {
|
||||
return apiFetch(`/exit-predictions/predict`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: 组合管理 ============
|
||||
|
||||
export async function rebalancePortfolio(companyReturns: unknown[]) {
|
||||
return apiFetch(`/portfolio/rebalance`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_returns: companyReturns }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMonteCarlo(companyReturns: number[], iterations?: number) {
|
||||
return apiFetch(`/portfolio/monte-carlo`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_returns: companyReturns, iterations }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: 数字孪生 ============
|
||||
|
||||
export async function listDigitalTwins(companyId: string) {
|
||||
return apiFetch(`/digital-twins?company_id=${companyId}`);
|
||||
}
|
||||
|
||||
export async function buildDigitalTwin(companyData: string) {
|
||||
return apiFetch(`/digital-twins/build`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function simulateTwin(modelParams: unknown, scenario: string) {
|
||||
return apiFetch(`/digital-twins/simulate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model_params: modelParams, scenario }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: 知识图谱 ============
|
||||
|
||||
export async function buildKnowledgeGraph(managementExperiences: string) {
|
||||
return apiFetch(`/knowledge-graph/build`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ management_experiences: managementExperiences }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function matchBestStrategy(companyProfile: string, graph: unknown) {
|
||||
return apiFetch(`/knowledge-graph/match-strategy`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ new_company_profile: companyProfile, knowledge_graph: graph }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: AAR ============
|
||||
|
||||
export async function listAARs(companyId?: string) {
|
||||
const path = companyId ? `/aars?company_id=${companyId}` : "/aars";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function generateAAR(triggerEvent: string, originalPlan: string, actualResult: string) {
|
||||
return apiFetch(`/aars/generate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ trigger_event: triggerEvent, original_plan: originalPlan, actual_result: actualResult }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: Pre-mortem + Red Team ============
|
||||
|
||||
export async function listPreMortems(companyId?: string) {
|
||||
const path = companyId ? `/pre-mortems?company_id=${companyId}` : "/pre-mortems";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function runPreMortem(decisionContext: string) {
|
||||
return apiFetch(`/pre-mortems/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision_context: decisionContext }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRedTeams(companyId?: string) {
|
||||
const path = companyId ? `/red-teams?company_id=${companyId}` : "/red-teams";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function runRedTeam(companyContext: string, perspective: string) {
|
||||
return apiFetch(`/red-teams/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_context: companyContext, perspective }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: Agent 执行 ============
|
||||
|
||||
export async function listAgentExecutions(page?: number) {
|
||||
return apiFetch(`/agent-executions?page=${page ?? 1}`);
|
||||
}
|
||||
|
||||
export async function orchestrateAgent(agentName: string, autonomyLevel: string, inputData: unknown) {
|
||||
return apiFetch(`/agent-executions/orchestrate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ agent_name: agentName, autonomy_level: autonomyLevel, input_data: inputData }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reviewAgentExecution(executionId: string, reviewStatus: string) {
|
||||
return apiFetch(`/agent-executions/${executionId}/review`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ review_status: reviewStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: 知识库 ============
|
||||
|
||||
export async function searchKnowledge(query: string, topK?: number) {
|
||||
return apiFetch(`/knowledge/search?q=${encodeURIComponent(query)}&top_k=${topK ?? 5}`);
|
||||
}
|
||||
|
||||
// ============ Phase 4: 数据源 ============
|
||||
|
||||
export async function listDataSources(companyId?: string) {
|
||||
const path = companyId ? `/data-sources?company_id=${companyId}` : "/data-sources";
|
||||
return apiFetch(path);
|
||||
}
|
||||
|
||||
export async function createDataSource(data: Record<string, unknown>) {
|
||||
return apiFetch(`/data-sources`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
// ============ Phase 4: 行业研究 ============
|
||||
|
||||
export async function researchIndustry(industry: string, companies: string) {
|
||||
return apiFetch(`/industry-research/research`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ industry, companies }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Phase 4: 基金 ============
|
||||
|
||||
export async function analyzeFundStrategy(fundsData: string) {
|
||||
return apiFetch(`/funds/analyze-strategy`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ funds_data: fundsData }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateLPReport(fundData: string, portfolioSummary: string) {
|
||||
return apiFetch(`/funds/lp-report`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fund_data: fundData, portfolio_summary: portfolioSummary }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Admin ============
|
||||
|
||||
export async function adminOverview() {
|
||||
return apiFetch(`/admin/overview`);
|
||||
}
|
||||
|
||||
export async function listTenants() {
|
||||
return apiFetch(`/admin/tenants`);
|
||||
}
|
||||
|
||||
export async function listUsers() {
|
||||
return apiFetch(`/admin/users`);
|
||||
}
|
||||
|
||||
export async function listAuditLogs(page?: number) {
|
||||
return apiFetch(`/admin/audit-logs?page=${page ?? 1}`);
|
||||
}
|
||||
|
||||
// ============ Founder ============
|
||||
|
||||
export async function founderOverview() {
|
||||
return apiFetch(`/founder/overview`);
|
||||
}
|
||||
|
||||
export async function founderHealth() {
|
||||
return apiFetch(`/founder/health`);
|
||||
}
|
||||
Reference in New Issue
Block a user