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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user