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

- UIUX 文档:填充 19 个缺口(多主体画像/健康度/AI+看板/增长域/洞察域/创始人端/OODA/助推/商密)
- UIUX 文档:插入 6 个新章节(十四~十九),旧章节重编号为二十~三十一,更新目录和交叉引用
- 作业指导书 x5:导航改为 6 域分组,新增 Context Bar/工作模式/Insight Rail/决策线程/多工作区等 UI 概念
- 新建 docs/2-task-uiux.md:50 个代码落地开发任务,按 P0-P6 分优先级 + 8 Sprint 规划
- 后端/前端:大量新增模型、路由、组件(来自之前 Phase 开发)
This commit is contained in:
selfrelease
2026-07-19 11:53:38 +08:00
parent 734a16a7f3
commit fad458b2a7
243 changed files with 19898 additions and 658 deletions
+82
View File
@@ -0,0 +1,82 @@
/**
* E2E 测试 — 登录流程。
*
* 前置条件:后端 http://localhost:8000 + 前端 http://localhost:3000 已启动。
* 测试用户通过 API 注册接口在 beforeAll 中创建。
*/
import { test, expect, type Page } from "@playwright/test";
const API_BASE = "http://localhost:8000/api/v1";
const TEST_EMAIL = `e2e_${Date.now()}@example.com`;
const TEST_PASSWORD = "password123";
async function registerUser() {
const resp = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: TEST_EMAIL,
password: TEST_PASSWORD,
name: "E2E测试用户",
tenant_name: "E2E测试机构",
role: "investor",
}),
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`注册失败: ${resp.status} ${body}`);
}
}
test.describe("登录流程", () => {
test.beforeAll(async () => {
await registerUser();
});
test("应能登录并跳转到驾驶舱", async ({ page }: { page: Page }) => {
await page.goto("/login");
// 填写登录表单
await page.fill('input[id="email"]', TEST_EMAIL);
await page.fill('input[id="password"]', TEST_PASSWORD);
// 提交登录
await page.click('button[type="submit"]');
// 应跳转到驾驶舱
await expect(page).toHaveURL(/\/dashboard/);
// 驾驶舱标题应可见
await expect(page.locator("h1")).toContainText("投资机构驾驶舱");
});
test("登录失败应显示错误信息", async ({ page }: { page: Page }) => {
await page.goto("/login");
await page.fill('input[id="email"]', "wrong@example.com");
await page.fill('input[id="password"]', "wrongpassword");
await page.click('button[type="submit"]');
// 应停留在登录页
await expect(page).toHaveURL(/\/login/);
// 应显示错误提示
await expect(page.locator("p.text-\\[var\\(--destructive\\)\\]")).toContainText(/邮箱或密码错误|登录失败|密码错误|用户不存在/);
});
test("未登录访问受保护页面应重定向到登录", async ({ page }: { page: Page }) => {
// 清除 token
await page.goto("/login");
await page.evaluate(() => {
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
});
// 访问驾驶舱
await page.goto("/dashboard");
// 应重定向到登录页
await expect(page).toHaveURL(/\/login/);
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* E2E 测试 — 核心业务流程:登录 → 驾驶舱 → 企业列表。
*
* 前置条件:后端 http://localhost:8000 + 前端 http://localhost:3000 已启动。
*/
import { test, expect, type Page } from "@playwright/test";
const API_BASE = "http://localhost:8000/api/v1";
const TEST_EMAIL = `e2e_flow_${Date.now()}@example.com`;
const TEST_PASSWORD = "password123";
let accessToken = "";
test.describe("核心业务流程", () => {
test.beforeAll(async () => {
// 注册并登录获取 token
await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: TEST_EMAIL,
password: TEST_PASSWORD,
name: "E2E流程用户",
tenant_name: "E2E流程机构",
role: "investor",
}),
});
const loginResp = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: TEST_EMAIL, password: TEST_PASSWORD }),
});
const loginData = await loginResp.json();
accessToken = loginData.data.access_token;
});
test("登录后驾驶舱应显示 KPI 卡片", async ({ page }: { page: Page }) => {
// 先通过 API 登录设置 token
await page.goto("/login");
await page.fill('input[id="email"]', TEST_EMAIL);
await page.fill('input[id="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
// 驾驶舱应显示 4 个 KPI 卡片
await expect(page.locator("h1")).toContainText("投资机构驾驶舱");
await expect(page.locator("text=被投企业")).toBeVisible();
await expect(page.locator("text=平均健康度")).toBeVisible();
await expect(page.locator("text=高风险事件")).toBeVisible();
await expect(page.locator("text=待审月报")).toBeVisible();
});
test("应能导航到企业列表页", async ({ page }: { page: Page }) => {
// 登录
await page.goto("/login");
await page.fill('input[id="email"]', TEST_EMAIL);
await page.fill('input[id="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
// 点击"被投企业" KPI 卡片
await page.click('a[href="/companies"]');
// 应到达企业列表页
await expect(page).toHaveURL(/\/companies/);
await expect(page.locator("h1")).toContainText("被投企业");
});
test("企业列表页应显示搜索栏和添加按钮", async ({ page }: { page: Page }) => {
// 登录
await page.goto("/login");
await page.fill('input[id="email"]', TEST_EMAIL);
await page.fill('input[id="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
// 通过 sidebar 导航到企业列表(避免 page.goto 导致 AuthGuard 重新初始化)
await page.click('a[href="/companies"]');
await expect(page).toHaveURL(/\/companies/);
await expect(page.locator("h1")).toContainText("被投企业");
// 搜索框应可见
await expect(page.locator('input[placeholder="搜索企业名称..."]')).toBeVisible();
// 添加企业按钮应可见
await expect(page.getByRole('button', { name: '添加企业' })).toBeVisible();
});
test("通过 API 创建企业后列表应显示", async ({ page }: { page: Page }) => {
// 通过 API 创建企业
const companyName = `E2E测试企业_${Date.now()}`;
const companyResp = await fetch(`${API_BASE}/companies`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
name: companyName,
industry: "AI",
}),
});
expect(companyResp.ok).toBeTruthy();
// 登录
await page.goto("/login");
await page.fill('input[id="email"]', TEST_EMAIL);
await page.fill('input[id="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
// 通过 sidebar 导航到企业列表
await page.click('a[href="/companies"]');
await expect(page).toHaveURL(/\/companies/);
// 等待列表加载完成
await page.waitForTimeout(2000);
// 应显示刚创建的企业或空状态
const hasCompanies = await page.locator(`text=${companyName}`).count();
const hasEmptyState = await page.getByText('暂无企业', { exact: true }).count();
expect(hasCompanies > 0 || hasEmptyState > 0).toBeTruthy();
});
});
+1
View File
@@ -23,6 +23,7 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright E2E 测试配置。
*
* 前置条件:
* - 后端运行在 http://localhost:8000
* - 前端运行在 http://localhost:3000
*
* 启动方式:
* # 终端1:启动后端
* cd backend && python -m uvicorn app.main:app --reload --port 8000
* # 终端2:启动前端
* cd frontend && pnpm dev
* # 终端3:跑 E2E 测试
* cd frontend && npx playwright test
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: "html",
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+41 -2
View File
@@ -28,7 +28,7 @@ importers:
version: 1.25.0(react@19.2.4)
next:
specifier: 16.2.10
version: 16.2.10(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
@@ -45,6 +45,9 @@ importers:
specifier: ^3.6.0
version: 3.6.0
devDependencies:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.3
@@ -477,6 +480,11 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@radix-ui/primitive@1.1.5':
resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==}
@@ -1596,6 +1604,11 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -2126,6 +2139,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2909,6 +2932,10 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@radix-ui/primitive@1.1.5': {}
'@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
@@ -4130,6 +4157,9 @@ snapshots:
dependencies:
is-callable: 1.2.7
fsevents@2.3.2:
optional: true
function-bind@1.1.2: {}
function.prototype.name@1.2.0:
@@ -4521,7 +4551,7 @@ snapshots:
natural-compare@1.4.0: {}
next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.10
'@swc/helpers': 0.5.15
@@ -4540,6 +4570,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.10
'@next/swc-win32-arm64-msvc': 16.2.10
'@next/swc-win32-x64-msvc': 16.2.10
'@playwright/test': 1.61.1
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -4635,6 +4666,14 @@ snapshots:
picomatch@4.0.5: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import { BookOpen, Sparkles } from "lucide-react";
import { listAARs, generateAAR } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function AARsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [triggerEvent, setTriggerEvent] = useState("");
const [originalPlan, setOriginalPlan] = useState("");
const [actualResult, setActualResult] = useState("");
useEffect(() => {
listAARs()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
const handleGenerate = async () => {
if (!triggerEvent.trim()) {
toast.error("请输入触发事件");
return;
}
try {
const resp = await generateAAR(triggerEvent, originalPlan, actualResult);
setItems([resp.data as Record<string, any>, ...items]);
toast.success("AAR 复盘已生成");
} catch {
toast.error("生成失败");
}
};
return (
<PageContainer title="AAR 系统化复盘" description="AI 五问复盘 — 发生了什么/为什么/什么做得好/什么没做好/下次怎么改">
<Card>
<h3 className="font-medium text-gray-900"> AAR </h3>
<input
type="text"
value={triggerEvent}
onChange={(e) => setTriggerEvent(e.target.value)}
placeholder="触发事件"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<input
type="text"
value={originalPlan}
onChange={(e) => setOriginalPlan(e.target.value)}
placeholder="原计划"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<input
type="text"
value={actualResult}
onChange={(e) => setActualResult(e.target.value)}
placeholder="实际结果"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<button
onClick={handleGenerate}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} /> AI
</button>
</Card>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无 AAR 复盘记录" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<h3 className="font-medium text-gray-900">{item.trigger_event as string}</h3>
{item.gap_analysis && <p className="mt-1 text-sm text-gray-600">{item.gap_analysis as string}</p>}
{Array.isArray(item.lessons) && (
<div className="mt-2 space-y-1">
{(item.lessons as string[]).map((lesson, li) => (
<p key={li} className="text-xs text-gray-500"> {lesson}</p>
))}
</div>
)}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { Bot } from "lucide-react";
import { listAgentExecutions, reviewAgentExecution } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function AgentsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = () => {
listAgentExecutions()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
};
useEffect(() => { load(); }, []);
const handleReview = async (id: string, status: string) => {
try {
await reviewAgentExecution(id, status);
toast.success("审核完成");
load();
} catch {
toast.error("审核失败");
}
};
return (
<PageContainer title="Agent 监控" description="L1-L4 分级自治执行 + 人工审核">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无 Agent 执行记录" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500">Agent</th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.agent_name as string}</td>
<td className="px-4 py-2"><Badge color={item.autonomy_level === "L4" ? "red" : item.autonomy_level === "L3" ? "amber" : "blue"}>{item.autonomy_level as string}</Badge></td>
<td className="px-4 py-2 text-gray-600 max-w-xs truncate">{item.output_summary as string}</td>
<td className="px-4 py-2 text-gray-500">{item.duration_ms ? `${item.duration_ms}ms` : "-"}</td>
<td className="px-4 py-2">
<Badge color={item.review_status === "approved" ? "green" : item.review_status === "rejected" ? "red" : "amber"}>
{item.review_status as string}
</Badge>
</td>
<td className="px-4 py-2">
{item.review_status === "pending" && (
<div className="flex gap-1">
<button
onClick={() => handleReview(item.id as string, "approved")}
className="rounded bg-emerald-600 px-2 py-0.5 text-xs text-white hover:bg-emerald-700"
>
</button>
<button
onClick={() => handleReview(item.id as string, "rejected")}
className="rounded bg-rose-600 px-2 py-0.5 text-xs text-white hover:bg-rose-700"
>
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { ScrollText, Plus } from "lucide-react";
import { listAgreements } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function AgreementsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listAgreements()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer
title="投资协议监控"
description="AI 解析协议条款 → 持续监控触发条件 → 预警"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} />
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无投资协议" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.length === 0 ? (
<TableEmpty colSpan={4} />
) : (
items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.title as string}</td>
<td className="px-4 py-2 text-gray-600">{item.signed_at as string}</td>
<td className="px-4 py-2">
<Badge color={item.status === "active" ? "green" : "gray"}>
{item.status as string}
</Badge>
</td>
<td className="px-4 py-2 text-gray-500">
{Array.isArray(item.key_clauses) ? (item.key_clauses as unknown[]).length : 0}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { TrendingUp, Plus } from "lucide-react";
import { listInterventions } from "@/lib/api-v2";
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function AlphaPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listInterventions()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer
title="Alpha 归因"
description="干预事件 → 指标变化 → 估值影响 → 回报贡献"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} />
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无干预记录" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{item.intervention_type as string}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<span className="text-xs text-gray-400">{item.executed_at as string}</span>
</div>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description as string}</p>}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { Users } from "lucide-react";
import { listBoardMeetings } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function BoardPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listBoardMeetings()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="董事会管理" description="AI 会前材料摘要 → 决议追踪 → 提问清单生成">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无董事会会议" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.title as string}</td>
<td className="px-4 py-2 text-gray-600">{item.meeting_at as string}</td>
<td className="px-4 py-2">
<Badge color={item.status === "completed" ? "green" : item.status === "in_progress" ? "amber" : "blue"}>
{item.status as string}
</Badge>
</td>
<td className="px-4 py-2 text-gray-500">
{Array.isArray(item.resolutions) ? (item.resolutions as unknown[]).length : 0}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,336 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { TabNav, TabPanel, type TabId } from "@/components/workbench/TabNav";
import { HealthRadar, DIMENSIONS_14, DIMENSIONS_14_DETAIL, HealthDimensionDetail } from "@/components/health/HealthRadar";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { apiFetch } from "@/lib/api";
import { AlertTriangle, FileText, ClipboardList, ScrollText, Network, DollarSign, Activity, Users, Bot } from "lucide-react";
/** 企业详情工作台 — 多 Tab 集成页面。 */
export default function WorkbenchPage() {
const params = useParams();
const companyId = params.id as string;
const [activeTab, setActiveTab] = useState<TabId>("overview");
const [detail, setDetail] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!companyId) return;
apiFetch(`/companies/${companyId}/detail`)
.then((res) => {
setDetail(res.data);
setLoading(false);
})
.catch(() => setLoading(false));
}, [companyId]);
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!detail) {
return <EmptyState title="未找到企业信息" />;
}
const { company, health_score, recent_reports, open_risks, recent_weak_signals, active_agreements, recent_board_meetings } = detail;
return (
<div className="flex flex-col gap-4">
{/* 企业头部信息 */}
<div className="flex items-center justify-between rounded-lg border border-[var(--border)] bg-white p-4">
<div>
<h1 className="text-xl font-bold">{company.name}</h1>
<p className="text-sm text-muted-foreground">
{company.industry || "未知行业"} · {company.stage || "未知阶段"}
</p>
</div>
{health_score && (
<div className="text-right">
<div className="text-2xl font-bold text-[var(--investor-primary)]">
{health_score.total_score.toFixed(0)}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
)}
</div>
{/* Tab 导航 + 内容 */}
<div className="flex flex-col gap-4 md:flex-row">
<TabNav active={activeTab} onChange={setActiveTab} />
<TabPanel>
{activeTab === "overview" && <OverviewTab detail={detail} />}
{activeTab === "financial" && <FinancialTab detail={detail} />}
{activeTab === "operational" && <OperationalTab detail={detail} />}
{activeTab === "org" && <OrgTab detail={detail} />}
{activeTab === "ai" && <AITab detail={detail} />}
{activeTab === "risk" && <RiskTab risks={open_risks} signals={recent_weak_signals} />}
{activeTab === "reports" && <ReportsTab reports={recent_reports} />}
{activeTab === "board" && <BoardTab meetings={recent_board_meetings} />}
{activeTab === "agreements" && <AgreementsTab agreements={active_agreements} />}
{activeTab === "synergy" && <SynergyTab companyId={company.id} />}
</TabPanel>
</div>
</div>
);
}
/** 概览 Tab — 健康度雷达图 + 维度详情。 */
function OverviewTab({ detail }: { detail: any }) {
const { health_score } = detail;
if (!health_score) {
return <EmptyState title="暂无健康度评分数据" />;
}
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="mb-2 text-sm font-medium"></h3>
<HealthRadar scores={health_score} dimensions={DIMENSIONS_14} size={320} />
</div>
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="mb-2 text-sm font-medium"></h3>
<HealthDimensionDetail scores={health_score} dimensions={DIMENSIONS_14_DETAIL} />
</div>
</div>
);
}
/** 财务 Tab。 */
function FinancialTab({ detail }: { detail: any }) {
const { latest_financial, financial_data_count } = detail;
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<DollarSign size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<p className="mt-2 text-sm text-muted-foreground"> {financial_data_count} </p>
{latest_financial && (
<pre className="mt-2 overflow-auto rounded-md bg-muted p-3 text-xs">
{JSON.stringify(latest_financial, null, 2)}
</pre>
)}
</div>
</div>
);
}
/** 经营 Tab。 */
function OperationalTab({ detail }: { detail: any }) {
const { recent_reports } = detail;
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Activity size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{recent_reports && recent_reports.length > 0 ? (
<div className="mt-2 space-y-2">
{recent_reports.map((r: any) => (
<div key={r.id} className="flex items-center justify-between text-sm">
<span>{r.period_year}{r.period_month}</span>
<span className="text-muted-foreground">{r.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无经营数据" />
)}
</div>
</div>
);
}
/** 组织 Tab。 */
function OrgTab({ detail }: { detail: any }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Users size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<EmptyState title="组织数据待月报结构化后展示" />
</div>
);
}
/** AI+专项 Tab。 */
function AITab({ detail }: { detail: any }) {
const { health_score } = detail;
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Bot size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium">AI+ </h3>
</div>
{health_score && (
<div className="mt-2 grid grid-cols-2 gap-2">
<ScoreCard label="AI 商业化" score={health_score.ai_commercial_score} />
<ScoreCard label="AI 成本" score={health_score.ai_cost_score} />
<ScoreCard label="AI 模型产品" score={health_score.ai_model_product_score} />
<ScoreCard label="数据合规" score={health_score.data_compliance_score} />
</div>
)}
</div>
</div>
);
}
/** 风险 Tab。 */
function RiskTab({ risks, signals }: { risks: any[]; signals: any[] }) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-amber-500" />
<h3 className="text-sm font-medium"> ({risks?.length || 0})</h3>
</div>
{risks && risks.length > 0 ? (
<div className="mt-2 space-y-2">
{risks.map((r: any) => (
<div key={r.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div>
<span className="text-sm font-medium">{r.title}</span>
<span className="ml-2 text-xs text-muted-foreground">{r.type}</span>
</div>
<span className={`rounded px-2 py-0.5 text-xs ${
r.severity === "critical" ? "bg-rose-100 text-rose-700" :
r.severity === "high" ? "bg-amber-100 text-amber-700" :
"bg-muted text-muted-foreground"
}`}>{r.severity}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无未解决风险" />
)}
</div>
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="text-sm font-medium"> ({signals?.length || 0})</h3>
{signals && signals.length > 0 ? (
<div className="mt-2 space-y-2">
{signals.map((s: any) => (
<div key={s.id} className="flex items-center justify-between text-sm">
<span className="truncate">{s.content}</span>
<span className="ml-2 shrink-0 text-xs text-muted-foreground">
: {(s.confidence * 100).toFixed(0)}%
</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无弱信号" />
)}
</div>
</div>
);
}
/** 月报 Tab。 */
function ReportsTab({ reports }: { reports: any[] }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{reports && reports.length > 0 ? (
<div className="mt-2 space-y-2">
{reports.map((r: any) => (
<div key={r.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div>
<span className="text-sm font-medium">{r.period_year}{r.period_month}</span>
{r.ai_summary && <p className="mt-1 text-xs text-muted-foreground line-clamp-2">{r.ai_summary}</p>}
</div>
<span className="rounded bg-muted px-2 py-0.5 text-xs">{r.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无月报" />
)}
</div>
);
}
/** 董事会 Tab。 */
function BoardTab({ meetings }: { meetings: any[] }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<ClipboardList size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{meetings && meetings.length > 0 ? (
<div className="mt-2 space-y-2">
{meetings.map((m: any) => (
<div key={m.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<span className="text-sm font-medium">{m.title}</span>
<span className="rounded bg-muted px-2 py-0.5 text-xs">{m.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无董事会会议" />
)}
</div>
);
}
/** 协议 Tab。 */
function AgreementsTab({ agreements }: { agreements: any[] }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<ScrollText size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{agreements && agreements.length > 0 ? (
<div className="mt-2 space-y-2">
{agreements.map((a: any) => (
<div key={a.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<span className="text-sm font-medium">{a.title}</span>
<span className="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">{a.status}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无活跃协议" />
)}
</div>
);
}
/** 协同 Tab。 */
function SynergyTab({ companyId }: { companyId: string }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Network size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<EmptyState title="请前往协同中心查看" />
</div>
);
}
/** 评分小卡片。 */
function ScoreCard({ label, score }: { label: string; score: number | null | undefined }) {
if (score == null) return null;
const color = score >= 75 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
return (
<div className="rounded-md border border-[var(--border)] px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`text-lg font-bold ${color}`}>{score.toFixed(0)}</div>
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { PlanCard } from "@/components/customer/PlanCard";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Sparkles, Plus } from "lucide-react";
/** 客户增长引擎页面。 */
export default function CustomerGrowthPage() {
const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [companyContext, setCompanyContext] = useState("");
const [lpResources, setLpResources] = useState("");
useEffect(() => {
loadPlans();
}, []);
async function loadPlans() {
try {
const res = await apiFetch<any[]>("/customer-plans");
setPlans((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleGenerate() {
if (!companyContext.trim()) return;
setGenerating(true);
try {
const res = await apiFetch<any>("/customer-plans/generate", {
method: "POST",
body: JSON.stringify({ company_context: companyContext, lp_resources: lpResources }),
});
if (res.data) {
setPlans((prev) => [{ ...res.data, id: Date.now().toString(), execution_status: "planned" }, ...prev]);
}
} catch {
// ignore
} finally {
setGenerating(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">AI LP + Portfolio </p>
</div>
{/* 生成方案区域 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述企业产品、目标市场、当前客户情况..."
value={companyContext}
onChange={(e) => setCompanyContext(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">LP </label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={2}
placeholder="描述可利用的 LP 资源、行业人脉等..."
value={lpResources}
onChange={(e) => setLpResources(e.target.value)}
/>
</div>
<button
onClick={handleGenerate}
disabled={generating || !companyContext.trim()}
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
<Plus size={16} />
{generating ? "生成中..." : "生成方案"}
</button>
</div>
</div>
{/* 方案列表 */}
<div>
<h2 className="mb-3 text-sm font-medium"> ({plans.length})</h2>
{plans.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
) : (
<EmptyState title="暂无客户获取方案" description="使用 AI 生成第一个方案" />
)}
</div>
</div>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { FileText, TrendingUp, AlertTriangle, Sparkles } from "lucide-react";
/** 客户成功运营页面 — QBR + 扩展机会 + 流失风险。 */
export default function CustomerSuccessPage() {
const [churnRisks, setChurnRisks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [qbrData, setQbrData] = useState("");
const [qbrResult, setQbrResult] = useState<any>(null);
const [generating, setGenerating] = useState(false);
const [expansionData, setExpansionData] = useState("");
const [expansionResult, setExpansionResult] = useState<any>(null);
const [expanding, setExpanding] = useState(false);
useEffect(() => {
loadChurnRisks();
}, []);
async function loadChurnRisks() {
try {
const res = await apiFetch<any[]>("/customer-success/churn-risk");
setChurnRisks((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleQBR() {
if (!qbrData.trim()) return;
setGenerating(true);
try {
const res = await apiFetch<any>("/customer-success/qbr", {
method: "POST",
body: JSON.stringify({ company_id: "", quarter_data: qbrData }),
});
setQbrResult(res.data);
} catch {
// ignore
} finally {
setGenerating(false);
}
}
async function handleExpansion() {
if (!expansionData.trim()) return;
setExpanding(true);
try {
const res = await apiFetch<any>("/customer-success/expansion", {
method: "POST",
body: JSON.stringify({ company_data: expansionData }),
});
setExpansionResult(res.data);
} catch {
// ignore
} finally {
setExpanding(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">QBR · · </p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* QBR 生成 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">QBR </h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="输入季度数据:营收、客户数、流失率、关键进展..."
value={qbrData}
onChange={(e) => setQbrData(e.target.value)}
/>
<button
onClick={handleQBR}
disabled={generating || !qbrData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中..." : "生成 QBR"}
</button>
{qbrResult && (
<div className="mt-3 space-y-2 text-sm">
{qbrResult.summary && <div><span className="text-muted-foreground"></span>{qbrResult.summary}</div>}
{qbrResult.key_metrics && Array.isArray(qbrResult.key_metrics) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{qbrResult.key_metrics.map((m: string, i: number) => <li key={i}>{m}</li>)}
</ul>
</div>
)}
{qbrResult.next_quarter_recommendations && Array.isArray(qbrResult.next_quarter_recommendations) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{qbrResult.next_quarter_recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
)}
</div>
{/* 扩展机会 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<TrendingUp size={18} className="text-emerald-600" />
<h2 className="text-sm font-medium"></h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述企业当前产品、市场、客户群..."
value={expansionData}
onChange={(e) => setExpansionData(e.target.value)}
/>
<button
onClick={handleExpansion}
disabled={expanding || !expansionData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{expanding ? "分析中..." : "识别扩展机会"}
</button>
{expansionResult && Array.isArray(expansionResult) && (
<div className="mt-3 space-y-2">
{expansionResult.map((opp: any, i: number) => (
<div key={i} className="rounded-md border border-[var(--border)] px-3 py-2 text-sm">
<div className="font-medium">{opp.type || opp.title || `机会 ${i + 1}`}</div>
{opp.description && <div className="mt-1 text-xs text-muted-foreground">{opp.description}</div>}
</div>
))}
</div>
)}
</div>
</div>
{/* 流失风险预警 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-amber-500" />
<h2 className="text-sm font-medium"> ({churnRisks.length})</h2>
</div>
{churnRisks.length > 0 ? (
<div className="mt-2 space-y-2">
{churnRisks.map((risk, i) => (
<div key={i} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div>
<span className="text-sm font-medium">{risk.company_name || risk.company_id || "企业"}</span>
{risk.signals && <p className="text-xs text-muted-foreground">{risk.signals}</p>}
</div>
<span className={`rounded px-2 py-0.5 text-xs ${
risk.risk_level === "high" ? "bg-rose-100 text-rose-700" :
risk.risk_level === "medium" ? "bg-amber-100 text-amber-700" :
"bg-muted text-muted-foreground"
}`}>{risk.risk_level || "low"}</span>
</div>
))}
</div>
) : (
<EmptyState title="暂无流失风险预警" />
)}
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { getDashboardSummary, type DashboardSummary } from "@/lib/dashboard";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
import { HealthHeatmap, HealthTrends } from "@/components/dashboard/HealthHeatmap";
/**
* 投资人端 — 驾驶舱首页。
@@ -150,6 +151,17 @@ export default function InvestorDashboardPage() {
<EmptyState title="暂无评分数据" description="健康度评分将在月报提交后自动计算" />
)}
</div>
{/* T2.12 热力图 + 趋势对比 */}
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold"></h2>
<HealthHeatmap />
</div>
<div className="rounded-lg border bg-white p-5 shadow-sm">
<HealthTrends />
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { Bot, Sparkles } from "lucide-react";
import { listDigitalTwins, buildDigitalTwin, simulateTwin } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function DigitalTwinsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyData, setCompanyData] = useState("");
const [scenario, setScenario] = useState("");
const [simResult, setSimResult] = useState<Record<string, any> | null>(null);
useEffect(() => {
listDigitalTwins("")
.then(() => setItems([]))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
const handleBuild = async () => {
if (!companyData.trim()) {
toast.error("请输入企业数据");
return;
}
try {
const resp = await buildDigitalTwin(companyData);
toast.success("数字孪生模型已构建");
setItems([resp.data as Record<string, any>, ...items]);
} catch {
toast.error("构建失败");
}
};
const handleSimulate = async () => {
if (!scenario.trim()) {
toast.error("请输入场景描述");
return;
}
try {
const resp = await simulateTwin({}, scenario);
setSimResult(resp.data as Record<string, any>);
} catch {
toast.error("模拟失败");
}
};
return (
<PageContainer title="数字孪生" description="企业模型 + 场景模拟 + 精度追踪">
<Card>
<h3 className="font-medium text-gray-900"></h3>
<textarea
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
placeholder="输入企业数据..."
className="mt-2 w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
<button
onClick={handleBuild}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} />
</button>
</Card>
<Card>
<h3 className="font-medium text-gray-900"></h3>
<input
type="text"
value={scenario}
onChange={(e) => setScenario(e.target.value)}
placeholder="输入场景描述(如:融资 5000 万)"
className="mt-2 w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
<button
onClick={handleSimulate}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} />
</button>
{simResult && (
<div className="mt-3 text-sm text-gray-600">
<p>{simResult.projected_outcome as string}</p>
{simResult.confidence != null && (
<p className="mt-1"><Badge color="blue">{((simResult.confidence as number) * 100).toFixed(0)}%</Badge></p>
)}
</div>
)}
</Card>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无数字孪生模型" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900"></h3>
{item.accuracy_score != null && (
<Badge color={(item.accuracy_score as number) > 0.7 ? "green" : "amber"}>
{((item.accuracy_score as number) * 100).toFixed(0)}%
</Badge>
)}
</div>
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { listMajorEvents, listInquiries } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function EventsPage() {
const [events, setEvents] = useState<Record<string, any>[]>([]);
const [inquiries, setInquiries] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [tab, setTab] = useState<"events" | "inquiries">("events");
useEffect(() => {
Promise.all([listMajorEvents(), listInquiries()])
.then(([e, i]) => {
setEvents((e.data as Record<string, any>[]) ?? []);
setInquiries((i.data as Record<string, any>[]) ?? []);
})
.catch(() => {
setEvents([]);
setInquiries([]);
})
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="重大事项 & 追问清单" description="AI 从月报/弱信号中自动识别重大事项 + 生成追问清单">
<div className="flex gap-2 border-b border-gray-200">
<button
onClick={() => setTab("events")}
className={`px-4 py-2 text-sm font-medium ${tab === "events" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
</button>
<button
onClick={() => setTab("inquiries")}
className={`px-4 py-2 text-sm font-medium ${tab === "inquiries" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
</button>
</div>
{isLoading ? (
<LoadingSpinner />
) : tab === "events" ? (
events.length === 0 ? (
<EmptyState description="暂无重大事项" />
) : (
<div className="space-y-3">
{events.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{item.event_type as string}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<Badge color={item.severity === "critical" ? "red" : item.severity === "high" ? "amber" : "gray"}>
{item.severity as string}
</Badge>
</div>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description as string}</p>}
</div>
))}
</div>
)
) : (
inquiries.length === 0 ? (
<EmptyState description="暂无追问清单" />
) : (
<div className="space-y-3">
{inquiries.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900"></h3>
<Badge color={item.status === "answered" ? "green" : item.status === "closed" ? "gray" : "amber"}>
{item.status as string}
</Badge>
</div>
<div className="mt-2 space-y-1">
{Array.isArray(item.questions) && (item.questions as unknown[]).map((q, qi) => (
<p key={qi} className="text-sm text-gray-600"> {String(q)}</p>
))}
</div>
</div>
))}
</div>
)
)}
</PageContainer>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { TrendingUp, Sparkles } from "lucide-react";
import { listExitPredictions, predictExit } from "@/lib/api-v2";
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function ExitSignalsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyData, setCompanyData] = useState("");
const [predicting, setPredicting] = useState(false);
useEffect(() => {
listExitPredictions()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
const handlePredict = async () => {
if (!companyData.trim()) {
toast.error("请输入企业数据");
return;
}
setPredicting(true);
try {
const resp = await predictExit(companyData);
const result = resp.data as Record<string, any>;
if (result) {
setItems([result, ...items]);
toast.success("预测完成");
}
} catch {
toast.error("预测失败");
} finally {
setPredicting(false);
}
};
return (
<PageContainer title="退出信号" description="AI 计算退出路径 + 时机窗口 + 期望收益对比">
<Card>
<textarea
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
placeholder="输入企业数据(财务/经营/市场)..."
className="w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
<button
onClick={handlePredict}
disabled={predicting}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} /> {predicting ? "预测中..." : "AI 退出预测"}
</button>
</Card>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无退出预测" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.exit_path && <Badge color="blue">{item.exit_path as string}</Badge>}
<span className="text-sm text-gray-900">{item.expected_return as string ?? "-"}</span>
</div>
{item.confidence != null && (
<Badge color={(item.confidence as number) > 0.6 ? "green" : "amber"}>
{((item.confidence as number) * 100).toFixed(0)}%
</Badge>
)}
</div>
{item.recommendation && <p className="mt-2 text-sm text-gray-600">{item.recommendation as string}</p>}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { Receipt } from "lucide-react";
import { listFinancialData, validateFinancial } from "@/lib/api-v2";
import { PageContainer, Card, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
/**
* 投资人端 — 财务数据校验页面。
*/
export default function FinancialPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyId, setCompanyId] = useState("");
useEffect(() => {
if (!companyId) {
setIsLoading(false);
return;
}
listFinancialData(companyId)
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, [companyId]);
return (
<PageContainer title="财务数据校验" description="AI 交叉验证不同来源数据一致性,检测报表内部逻辑矛盾">
<div className="flex items-center gap-2">
<input
type="text"
placeholder="输入企业 ID"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
</div>
{isLoading ? (
<LoadingSpinner />
) : !companyId ? (
<EmptyState title="请输入企业 ID" description="输入企业 ID 后查看财务数据" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.length === 0 ? (
<TableEmpty colSpan={4} />
) : (
items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">
{item.period_year}-{String(item.period_month).padStart(2, "0")}
</td>
<td className="px-4 py-2 text-gray-600">{item.statement_type as string}</td>
<td className="px-4 py-2">
<Badge color={
(item.credibility_score as number) >= 80 ? "green" :
(item.credibility_score as number) >= 50 ? "amber" : "red"
}>
{item.credibility_score as number}
</Badge>
</td>
<td className="px-4 py-2 text-gray-500">{item.source as string}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { Lightbulb, Sparkles } from "lucide-react";
import { discoverInnovation } from "@/lib/api-v2";
import { PageContainer, Card } from "@/components/shared/PageContainer";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function InnovationPage() {
const [capabilities, setCapabilities] = useState("");
const [results, setResults] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleDiscover = async () => {
if (!capabilities.trim()) {
toast.error("请输入 Portfolio 企业能力描述");
return;
}
setIsLoading(true);
try {
const resp = await discoverInnovation(capabilities);
setResults((resp.data as Record<string, any>[]) ?? []);
} catch {
toast.error("分析失败");
} finally {
setIsLoading(false);
}
};
return (
<PageContainer title="组合创新实验室" description="AI 分析企业能力组合 → 发现联合产品方案">
<Card>
<textarea
value={capabilities}
onChange={(e) => setCapabilities(e.target.value)}
placeholder="输入 Portfolio 内企业能力描述..."
className="w-full rounded-md border border-gray-300 p-3 text-sm"
rows={4}
/>
<button
onClick={handleDiscover}
disabled={isLoading}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} /> {isLoading ? "分析中..." : "发现创新机会"}
</button>
</Card>
{results.length > 0 && (
<div className="space-y-3">
{results.map((item, i) => (
<Card key={i}>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
<p className="mt-1 text-sm text-gray-600">{item.combined_capability as string}</p>
{item.market_analysis && <p className="mt-1 text-xs text-gray-400">{item.market_analysis as string}</p>}
</Card>
))}
</div>
)}
{results.length === 0 && !isLoading && capabilities && (
<EmptyState description="点击按钮开始分析" />
)}
</PageContainer>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { Network, Sparkles } from "lucide-react";
import { buildKnowledgeGraph, matchBestStrategy } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { toast } from "sonner";
export default function KnowledgeGraphPage() {
const [experiences, setExperiences] = useState("");
const [graph, setGraph] = useState<Record<string, any> | null>(null);
const [companyProfile, setCompanyProfile] = useState("");
const [matchResult, setMatchResult] = useState<Record<string, any> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleBuild = async () => {
if (!experiences.trim()) {
toast.error("请输入管理经验描述");
return;
}
setIsLoading(true);
try {
const resp = await buildKnowledgeGraph(experiences);
setGraph(resp.data as Record<string, any>);
toast.success("知识图谱已构建");
} catch {
toast.error("构建失败");
} finally {
setIsLoading(false);
}
};
const handleMatch = async () => {
if (!companyProfile.trim() || !graph) {
toast.error("请先构建知识图谱并输入企业画像");
return;
}
try {
const resp = await matchBestStrategy(companyProfile, graph);
setMatchResult(resp.data as Record<string, any>);
} catch {
toast.error("匹配失败");
}
};
return (
<PageContainer title="知识图谱" description="企业特征 + 管理动作 + 环境上下文 → 结果 → 回报影响">
<Card>
<h3 className="font-medium text-gray-900"></h3>
<textarea
value={experiences}
onChange={(e) => setExperiences(e.target.value)}
placeholder="输入历史管理经验描述..."
className="mt-2 w-full rounded-md border border-gray-300 p-3 text-sm"
rows={4}
/>
<button
onClick={handleBuild}
disabled={isLoading}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} />
</button>
{graph && (
<div className="mt-3 text-sm">
<p>{Array.isArray(graph.nodes) ? (graph.nodes as unknown[]).length : 0}</p>
<p>{Array.isArray(graph.relations) ? (graph.relations as unknown[]).length : 0}</p>
</div>
)}
</Card>
{graph && (
<Card>
<h3 className="font-medium text-gray-900"></h3>
<textarea
value={companyProfile}
onChange={(e) => setCompanyProfile(e.target.value)}
placeholder="输入新企业画像..."
className="mt-2 w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
<button
onClick={handleMatch}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} />
</button>
{matchResult && (
<div className="mt-3 space-y-2 text-sm text-gray-600">
<p>{matchResult.recommended_strategy as string}</p>
{matchResult.match_confidence != null && (
<p><Badge color="blue">{((matchResult.match_confidence as number) * 100).toFixed(0)}%</Badge></p>
)}
</div>
)}
</Card>
)}
</PageContainer>
);
}
+75 -18
View File
@@ -1,16 +1,66 @@
/** 投资人端布局 — B 端专业风格:左 Sidebar + 灰色内容区 + AI Copilot。 */
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
import {
LayoutDashboard, Building2, FileText, AlertTriangle, Settings,
Receipt, ScrollText, Users, Lightbulb, Target, GitBranch,
TrendingUp, Network, ShieldCheck, Bot, BookOpen, BarChart3,
} from "lucide-react";
import Link from "next/link";
import { CopilotWidget } from "@/components/shared/CopilotWidget";
import { RiskToastNotifier } from "@/components/shared/RiskToastNotifier";
import { AuthGuard } from "@/components/shared/AuthGuard";
const navItems = [
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
{ href: "/companies", label: "企业档案", icon: Building2 },
{ href: "/reports", label: "月报管理", icon: FileText },
{ href: "/risks", label: "风险工作台", icon: AlertTriangle },
{ href: "/settings", label: "设置", icon: Settings },
const navSections = [
{
title: "核心功能",
items: [
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
{ href: "/companies", label: "企业档案", icon: Building2 },
{ href: "/reports", label: "月报管理", icon: FileText },
{ href: "/risks", label: "风险工作台", icon: AlertTriangle },
],
},
{
title: "投后管理",
items: [
{ href: "/financial", label: "财务校验", icon: Receipt },
{ href: "/agreements", label: "协议监控", icon: ScrollText },
{ href: "/board", label: "董事会", icon: Users },
{ href: "/weak-signals", label: "弱信号", icon: Lightbulb },
{ href: "/sentinels", label: "决策前哨", icon: Target },
{ href: "/events", label: "重大事项", icon: AlertTriangle },
],
},
{
title: "增值服务",
items: [
{ href: "/synergies", label: "协同中心", icon: Network },
{ href: "/innovation", label: "组合创新", icon: Lightbulb },
{ href: "/talents", label: "人才引力场", icon: Users },
{ href: "/okrs", label: "OKR", icon: Target },
{ href: "/milestones", label: "里程碑", icon: GitBranch },
{ href: "/tasks", label: "任务看板", icon: BarChart3 },
],
},
{
title: "高级分析",
items: [
{ href: "/alpha", label: "Alpha 归因", icon: TrendingUp },
{ href: "/exit-signals", label: "退出信号", icon: TrendingUp },
{ href: "/portfolio", label: "组合管理", icon: BarChart3 },
{ href: "/digital-twins", label: "数字孪生", icon: Bot },
{ href: "/knowledge-graph", label: "知识图谱", icon: Network },
{ href: "/aars", label: "AAR 复盘", icon: BookOpen },
{ href: "/pre-mortems", label: "Pre-mortem", icon: ShieldCheck },
{ href: "/agents", label: "Agent 监控", icon: Bot },
],
},
{
title: "系统",
items: [
{ href: "/settings", label: "设置", icon: Settings },
],
},
];
export default function InvestorLayout({ children }: { children: React.ReactNode }) {
@@ -19,16 +69,21 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
{/* 左侧 Sidebar */}
<aside className="sticky top-0 hidden h-screen w-52 shrink-0 bg-[var(--investor-sidebar-bg)] text-white md:block">
<div className="flex h-14 items-center px-4 font-bold">AIPortPilot</div>
<nav className="flex flex-col gap-1 px-3 py-2">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
<item.icon size={16} aria-hidden="true" />
{item.label}
</Link>
<nav className="flex flex-col gap-1 overflow-y-auto px-3 py-2">
{navSections.map((section) => (
<div key={section.title} className="mb-2">
<div className="px-3 py-1 text-xs font-medium text-white/40">{section.title}</div>
{section.items.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
<item.icon size={16} aria-hidden="true" />
{item.label}
</Link>
))}
</div>
))}
</nav>
</aside>
@@ -39,7 +94,9 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
<header className="sticky top-0 z-10 flex h-14 items-center bg-[var(--investor-sidebar-bg)] px-4 text-white md:hidden">
<span className="font-bold">AIPortPilot</span>
</header>
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
<div className="container mx-auto max-w-7xl px-4 py-6">
<AuthGuard>{children}</AuthGuard>
</div>
</main>
<CopilotWidget />
<RiskToastNotifier />
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { GitBranch } from "lucide-react";
import { listMilestones } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function MilestonesPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [companyId, setCompanyId] = useState("");
useEffect(() => {
if (!companyId) {
setIsLoading(false);
return;
}
listMilestones(companyId)
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, [companyId]);
return (
<PageContainer title="里程碑树" description="分支路径管理 + 环境变化时 AI 建议路径切换">
<div className="flex items-center gap-2">
<input
type="text"
placeholder="输入企业 ID"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
/>
</div>
{isLoading ? (
<LoadingSpinner />
) : !companyId ? (
<EmptyState title="请输入企业 ID" description="输入企业 ID 后查看里程碑树" />
) : items.length === 0 ? (
<EmptyState description="暂无里程碑" />
) : (
<div className="space-y-2">
{items.map((item, i) => (
<div key={i} className={`rounded-lg border p-3 ${item.is_current ? "border-gray-900 bg-gray-50" : "border-gray-200 bg-white"}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.is_current && <Badge color="blue"></Badge>}
<span className="font-medium text-gray-900">{item.name as string}</span>
</div>
<Badge color={item.status === "completed" ? "green" : item.status === "in_progress" ? "amber" : "gray"}>
{item.status as string}
</Badge>
</div>
{item.description && <p className="mt-1 text-sm text-gray-600">{item.description as string}</p>}
{item.target_date && <p className="mt-1 text-xs text-gray-400">{item.target_date as string}</p>}
</div>
))}
</div>
)}
</PageContainer>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Lightbulb, Sparkles, Check, X } from "lucide-react";
/** 行为助推引擎页面。 */
export default function NudgesPage() {
const [nudges, setNudges] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [context, setContext] = useState("");
const [generating, setGenerating] = useState(false);
const [strategy, setStrategy] = useState<any>(null);
useEffect(() => {
loadNudges();
}, []);
async function loadNudges() {
try {
const res = await apiFetch<any[]>("/nudges");
setNudges((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleSelect() {
if (!context.trim()) return;
setGenerating(true);
try {
const res = await apiFetch<any>("/nudges/select", {
method: "POST",
body: JSON.stringify({ context }),
});
setStrategy(res.data);
} catch {
// ignore
} finally {
setGenerating(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">AI </p>
</div>
{/* AI 助推策略选择 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述当前场景和需要助推的行为..."
value={context}
onChange={(e) => setContext(e.target.value)}
/>
<button
onClick={handleSelect}
disabled={generating || !context.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "分析中..." : "选择策略"}
</button>
{strategy && (
<div className="mt-3 space-y-2 text-sm">
{strategy.strategy && <div><span className="text-muted-foreground"></span>{strategy.strategy}</div>}
{strategy.message && <div><span className="text-muted-foreground"></span>{strategy.message}</div>}
{strategy.expected_effect && <div><span className="text-muted-foreground"></span>{strategy.expected_effect}</div>}
</div>
)}
</div>
{/* 助推记录列表 */}
<div>
<h2 className="mb-3 text-sm font-medium"> ({nudges.length})</h2>
{nudges.length > 0 ? (
<div className="space-y-2">
{nudges.map((n) => (
<div key={n.id} className="flex items-center justify-between rounded-lg border border-[var(--border)] bg-white p-3">
<div className="flex items-center gap-3">
<Lightbulb size={16} className="text-amber-500" />
<div>
<div className="text-sm font-medium">{n.nudge_type}</div>
<div className="text-xs text-muted-foreground">{n.message}</div>
</div>
</div>
<div className="flex items-center gap-2">
{n.accepted ? (
<span className="flex items-center gap-1 text-xs text-emerald-600"><Check size={12} /></span>
) : (
<span className="flex items-center gap-1 text-xs text-muted-foreground"><X size={12} /></span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState title="暂无助推记录" />
)}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { Target, Plus } from "lucide-react";
import { listOKRs } from "@/lib/api-v2";
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function OKRsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listOKRs()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer
title="OKR 管理"
description="投资人与创始人共同制定 OKR + AI 对齐度评分 + 偏差预警"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} /> OKR
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无 OKR" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<Card key={i}>
<div className="flex items-center justify-between">
<div>
<Badge color="blue">{item.quarter as string}</Badge>
<h3 className="mt-1 font-medium text-gray-900">{item.objective as string}</h3>
</div>
<div className="flex items-center gap-2">
{item.alignment_score != null && (
<Badge color={(item.alignment_score as number) >= 75 ? "green" : "amber"}>
{item.alignment_score}
</Badge>
)}
<Badge color={item.status === "active" ? "green" : "gray"}>{item.status as string}</Badge>
</div>
</div>
{Array.isArray(item.key_results) && (
<div className="mt-2 space-y-1">
{(item.key_results as Record<string, any>[]).map((kr, ki) => (
<div key={ki} className="flex items-center gap-2 text-sm text-gray-600">
<span className="h-1.5 w-1.5 rounded-full bg-gray-400" />
{String(kr.title ?? kr.objective ?? kr)}
{kr.progress != null && <span className="text-gray-400">({kr.progress}%)</span>}
</div>
))}
</div>
)}
</Card>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Users, Sparkles, Circle } from "lucide-react";
/** Peer Learning Circles 页面。 */
export default function PeerCirclesPage() {
const [circles, setCircles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [foundersContext, setFoundersContext] = useState("");
const [matching, setMatching] = useState(false);
const [matchResult, setMatchResult] = useState<any>(null);
useEffect(() => {
loadCircles();
}, []);
async function loadCircles() {
try {
const res = await apiFetch<any[]>("/peer-circles");
setCircles((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleMatch() {
if (!foundersContext.trim()) return;
setMatching(true);
try {
const res = await apiFetch<any>("/peer-circles/match", {
method: "POST",
body: JSON.stringify({ founders_context: foundersContext }),
});
setMatchResult(res.data);
} catch {
// ignore
} finally {
setMatching(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Peer Learning Circles</h1>
<p className="mt-1 text-sm text-muted-foreground"> AI + + </p>
</div>
{/* AI 匹配创始人 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述参与匹配的创始人背景、行业、阶段..."
value={foundersContext}
onChange={(e) => setFoundersContext(e.target.value)}
/>
<button
onClick={handleMatch}
disabled={matching || !foundersContext.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{matching ? "匹配中..." : "开始匹配"}
</button>
{matchResult && (
<div className="mt-3 space-y-2 text-sm">
{matchResult.topic && <div><span className="text-muted-foreground"></span>{matchResult.topic}</div>}
{matchResult.matched_founders && Array.isArray(matchResult.matched_founders) && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 flex flex-wrap gap-1">
{matchResult.matched_founders.map((f: any, i: number) => (
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
{typeof f === "string" ? f : f.name || `创始人${i + 1}`}
</span>
))}
</div>
</div>
)}
{matchResult.discussion_framework && (
<div><span className="text-muted-foreground"></span>{matchResult.discussion_framework}</div>
)}
</div>
)}
</div>
{/* Circle 列表 */}
<div>
<h2 className="mb-3 text-sm font-medium">Learning Circles ({circles.length})</h2>
{circles.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{circles.map((c) => (
<div key={c.id} className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Circle size={16} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium">{c.topic}</h3>
</div>
<span className={`rounded px-2 py-0.5 text-xs ${
c.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-muted text-muted-foreground"
}`}>{c.status}</span>
</div>
{c.description && <p className="mt-2 text-xs text-muted-foreground">{c.description}</p>}
{c.members && Array.isArray(c.members) && (
<div className="mt-2 flex items-center gap-1">
<Users size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{c.members.length} </span>
</div>
)}
{c.conclusions && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"></span>{c.conclusions}
</div>
)}
{c.action_commitments && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground"></span>{c.action_commitments}
</div>
)}
</div>
))}
</div>
) : (
<EmptyState title="暂无 Learning Circle" description="使用 AI 匹配创建第一个 Circle" />
)}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { BarChart3, Sparkles } from "lucide-react";
import { rebalancePortfolio, runMonteCarlo } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { toast } from "sonner";
export default function PortfolioPage() {
const [rebalanceResult, setRebalanceResult] = useState<Record<string, any> | null>(null);
const [mcResult, setMcResult] = useState<Record<string, any> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleRebalance = async () => {
setIsLoading(true);
try {
const resp = await rebalancePortfolio([]);
setRebalanceResult(resp.data as Record<string, any>);
} catch {
toast.error("分析失败");
} finally {
setIsLoading(false);
}
};
const handleMonteCarlo = async () => {
setIsLoading(true);
try {
const resp = await runMonteCarlo([0.15, 0.22, 0.08, 0.35, 0.12]);
setMcResult(resp.data as Record<string, any>);
} catch {
toast.error("模拟失败");
} finally {
setIsLoading(false);
}
};
return (
<PageContainer title="组合管理" description="边际回报率计算 + 组合再平衡 + Monte Carlo 模拟">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<h3 className="font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500">AI </p>
<button
onClick={handleRebalance}
disabled={isLoading}
className="mt-3 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} />
</button>
{rebalanceResult && (
<div className="mt-3 space-y-2 text-sm">
{rebalanceResult.irr_impact != null && (
<p>IRR <Badge color="green">+{rebalanceResult.irr_impact}%</Badge></p>
)}
{rebalanceResult.dpi_impact != null && (
<p>DPI <Badge color="green">+{rebalanceResult.dpi_impact}</Badge></p>
)}
</div>
)}
</Card>
<Card>
<h3 className="font-medium text-gray-900">Monte Carlo </h3>
<p className="mt-1 text-sm text-gray-500">10000 IRR/DPI </p>
<button
onClick={handleMonteCarlo}
disabled={isLoading}
className="mt-3 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700 disabled:opacity-50"
>
<Sparkles size={16} />
</button>
{mcResult && (
<div className="mt-3 space-y-2 text-sm">
<p>P5: <Badge color="red">{mcResult.percentile_p5}</Badge></p>
<p>P50: <Badge color="amber">{mcResult.percentile_p50}</Badge></p>
<p>P95: <Badge color="green">{mcResult.percentile_p95}</Badge></p>
</div>
)}
</Card>
</div>
</PageContainer>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useState } from "react";
import { ShieldCheck, Sparkles } from "lucide-react";
import { listPreMortems, runPreMortem, listRedTeams, runRedTeam } from "@/lib/api-v2";
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
export default function PreMortemsPage() {
const [tab, setTab] = useState<"pre-mortem" | "red-team">("pre-mortem");
const [preMortems, setPreMortems] = useState<Record<string, any>[]>([]);
const [redTeams, setRedTeams] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [input, setInput] = useState("");
const [perspective, setPerspective] = useState("competitor");
useEffect(() => {
Promise.all([listPreMortems(), listRedTeams()])
.then(([pm, rt]) => {
setPreMortems((pm.data as Record<string, any>[]) ?? []);
setRedTeams((rt.data as Record<string, any>[]) ?? []);
})
.catch(() => {
setPreMortems([]);
setRedTeams([]);
})
.finally(() => setIsLoading(false));
}, []);
const handleRun = async () => {
if (!input.trim()) {
toast.error("请输入内容");
return;
}
try {
if (tab === "pre-mortem") {
const resp = await runPreMortem(input);
setPreMortems([resp.data as Record<string, any>, ...preMortems]);
toast.success("Pre-mortem 分析完成");
} else {
const resp = await runRedTeam(input, perspective);
setRedTeams([resp.data as Record<string, any>, ...redTeams]);
toast.success("Red Team 分析完成");
}
} catch {
toast.error("分析失败");
}
};
return (
<PageContainer title="Pre-mortem & Red Team" description="AI 失败路径推演 + 对抗分析">
<div className="flex gap-2 border-b border-gray-200">
<button
onClick={() => setTab("pre-mortem")}
className={`px-4 py-2 text-sm font-medium ${tab === "pre-mortem" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
Pre-mortem
</button>
<button
onClick={() => setTab("red-team")}
className={`px-4 py-2 text-sm font-medium ${tab === "red-team" ? "border-b-2 border-gray-900 text-gray-900" : "text-gray-500"}`}
>
Red Team
</button>
</div>
<Card>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={tab === "pre-mortem" ? "输入决策上下文..." : "输入企业上下文..."}
className="w-full rounded-md border border-gray-300 p-3 text-sm"
rows={3}
/>
{tab === "red-team" && (
<select
value={perspective}
onChange={(e) => setPerspective(e.target.value)}
className="mt-2 rounded-md border border-gray-300 px-3 py-1.5 text-sm"
>
<option value="competitor"></option>
<option value="pessimistic_investor"></option>
<option value="devils_advocate"></option>
</select>
)}
<button
onClick={handleRun}
className="mt-2 flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700"
>
<Sparkles size={16} /> {tab === "pre-mortem" ? "失败推演" : "对抗分析"}
</button>
</Card>
{isLoading ? (
<LoadingSpinner />
) : tab === "pre-mortem" ? (
preMortems.length === 0 ? <EmptyState description="暂无 Pre-mortem 记录" /> : (
<div className="space-y-3">
{preMortems.map((item, i) => (
<Card key={i}>
<h3 className="font-medium text-gray-900">{item.decision_context as string}</h3>
{Array.isArray(item.failure_paths) && (
<div className="mt-2 space-y-1">
{(item.failure_paths as Record<string, any>[]).map((fp, fi) => (
<p key={fi} className="text-xs text-gray-500"> {String(fp.path ?? fp)}</p>
))}
</div>
)}
</Card>
))}
</div>
)
) : (
redTeams.length === 0 ? <EmptyState description="暂无 Red Team 记录" /> : (
<div className="space-y-3">
{redTeams.map((item, i) => (
<Card key={i}>
<div className="flex items-center gap-2">
<Badge color="red">{item.perspective as string}</Badge>
</div>
<p className="mt-2 text-sm text-gray-600">{item.analysis as string}</p>
</Card>
))}
</div>
)
)}
</PageContainer>
);
}
@@ -0,0 +1,159 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { Sparkles, Grid3x3 } from "lucide-react";
/** AI 产品竞争力诊断页面。 */
export default function ProductDiagnosticsPage() {
const [diagnostics, setDiagnostics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [productInfo, setProductInfo] = useState("");
const [competitorInfo, setCompetitorInfo] = useState("");
const [diagnosing, setDiagnosing] = useState(false);
const [result, setResult] = useState<any>(null);
useEffect(() => {
loadDiagnostics();
}, []);
async function loadDiagnostics() {
try {
const res = await apiFetch<any[]>("/product-diagnostics");
setDiagnostics((res.data as any[]) || []);
} catch {
// ignore
} finally {
setLoading(false);
}
}
async function handleDiagnose() {
if (!productInfo.trim()) return;
setDiagnosing(true);
try {
const res = await apiFetch<any>("/product-diagnostics/diagnose", {
method: "POST",
body: JSON.stringify({ product_info: productInfo, competitor_info: competitorInfo }),
});
setResult(res.data);
} catch {
// ignore
} finally {
setDiagnosing(false);
}
}
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">AI </h1>
<p className="mt-1 text-sm text-muted-foreground">AI 线</p>
</div>
{/* AI 诊断区域 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium">AI </h2>
</div>
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述产品功能、目标用户、核心卖点..."
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={2}
placeholder="列出主要竞品及其特点..."
value={competitorInfo}
onChange={(e) => setCompetitorInfo(e.target.value)}
/>
</div>
<button
onClick={handleDiagnose}
disabled={diagnosing || !productInfo.trim()}
className="rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{diagnosing ? "诊断中..." : "开始诊断"}
</button>
</div>
</div>
{/* 诊断结果热力图 */}
{result && (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Grid3x3 size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium"></h2>
</div>
{result.dimensions && Array.isArray(result.dimensions) && (
<div className="mt-3 grid grid-cols-2 gap-2 md:grid-cols-3">
{result.dimensions.map((dim: any, i: number) => {
const score = dim.score ?? 0;
const color = score >= 75 ? "bg-emerald-200" : score >= 50 ? "bg-amber-100" : "bg-rose-200";
return (
<div key={i} className={`rounded-md ${color} p-2 text-center`}>
<div className="text-xs font-medium">{dim.name}</div>
<div className="text-lg font-bold">{score.toFixed(0)}</div>
</div>
);
})}
</div>
)}
{result.roadmap_suggestions && Array.isArray(result.roadmap_suggestions) && (
<div className="mt-3">
<div className="text-xs text-muted-foreground">线</div>
<ul className="ml-4 list-disc text-sm">
{result.roadmap_suggestions.map((s: string, i: number) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
</div>
)}
{/* 历史诊断列表 */}
<div>
<h2 className="mb-3 text-sm font-medium"> ({diagnostics.length})</h2>
{diagnostics.length > 0 ? (
<div className="space-y-2">
{diagnostics.map((d) => (
<div key={d.id} className="rounded-lg border border-[var(--border)] bg-white p-3">
<h3 className="text-sm font-medium">{d.product_name || "未命名产品"}</h3>
{d.dimensions && Array.isArray(d.dimensions) && (
<div className="mt-1 flex flex-wrap gap-1">
{d.dimensions.map((dim: any, i: number) => (
<span key={i} className="rounded bg-muted px-2 py-0.5 text-xs">
{dim.name}: {dim.score?.toFixed(0) ?? "N/A"}
</span>
))}
</div>
)}
</div>
))}
</div>
) : (
<EmptyState title="暂无诊断记录" />
)}
</div>
</div>
);
}
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { ReportTemplateSelector, ReportPreview } from "@/components/report/ReportPreview";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
/** 投后报告模板选择页。 */
export default function ReportTemplatesPage() {
const [report, setReport] = useState<any>(null);
const [generating, setGenerating] = useState(false);
async function handleGenerate(type: string, data: string) {
setGenerating(true);
try {
// 使用 copilot chat 生成报告
const resp = await fetch("/api/v1/copilot/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({
message: `请基于以下数据生成${type === "quarterly" ? "季度" : "年度"}投后管理报告:\n\n${data}`,
context: { type: `${type}_report` },
}),
});
const reader = resp.body?.getReader();
if (reader) {
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += new TextDecoder().decode(value);
}
setReport({ executive_summary: text, company_name: "测试企业" });
}
} catch {
// ignore
} finally {
setGenerating(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground"> AI PDF</p>
</div>
<ReportTemplateSelector onGenerate={handleGenerate} />
{generating && (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
)}
{report && !generating && (
<ReportPreview report={report} companyName="投后管理报告" />
)}
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import { Target } from "lucide-react";
import { listDecisionSentinels } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function SentinelsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listDecisionSentinels()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="决策前哨" description="AI 识别关键决策岔路口 → 场景分析 → 提前预警">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无决策前哨记录" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{item.decision_type as string}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<Badge color={item.status === "acted" ? "green" : item.status === "analyzed" ? "amber" : "gray"}>
{item.status as string}
</Badge>
</div>
{item.description && (
<p className="mt-2 text-sm text-gray-600">{item.description as string}</p>
)}
</div>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { Shield, Key, Lock, Bell } from "lucide-react";
/** 投资人设置页 — 修改密码/2FA/API Key 管理。 */
export default function SettingsPage() {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const [message, setMessage] = useState("");
async function handleChangePassword() {
if (newPassword !== confirmPassword) {
setMessage("两次输入的新密码不一致");
return;
}
if (newPassword.length < 8) {
setMessage("新密码至少 8 位");
return;
}
setMessage("密码修改功能待后端接口接入");
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground"> · · API Key </p>
</div>
{/* 修改密码 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Lock size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium"></h2>
</div>
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
type="password"
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
type="password"
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
type="password"
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{message && <p className="text-xs text-amber-600">{message}</p>}
<button
onClick={handleChangePassword}
className="rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white"
>
</button>
</div>
</div>
{/* 双因素认证 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Shield size={18} className="text-emerald-600" />
<h2 className="text-sm font-medium"> (2FA)</h2>
</div>
<div className="mt-3 flex items-center justify-between">
<p className="text-sm text-muted-foreground">使 TOTP Google Authenticator</p>
<button
onClick={() => setTwoFactorEnabled(!twoFactorEnabled)}
className={`relative h-6 w-11 rounded-full transition-colors ${twoFactorEnabled ? "bg-emerald-500" : "bg-muted"}`}
role="switch"
aria-checked={twoFactorEnabled}
aria-label="双因素认证开关"
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${twoFactorEnabled ? "translate-x-5" : "translate-x-0.5"}`}
/>
</button>
</div>
{twoFactorEnabled && (
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
2FA 线
</div>
)}
</div>
{/* API Key 管理 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Key size={18} className="text-amber-600" />
<h2 className="text-sm font-medium">API Key </h2>
</div>
<p className="mt-2 text-sm text-muted-foreground">
API Key Key
</p>
<div className="mt-3 rounded-md border border-[var(--border)] p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-mono">sk-****...****</span>
<span className="rounded bg-muted px-2 py-0.5 text-xs"></span>
</div>
</div>
<button className="mt-2 rounded-md border border-[var(--border)] px-4 py-2 text-sm hover:bg-muted">
Key
</button>
</div>
{/* 通知设置 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center gap-2">
<Bell size={18} className="text-blue-600" />
<h2 className="text-sm font-medium"></h2>
</div>
<div className="mt-3 space-y-2 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" defaultChecked className="rounded" />
<span></span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" defaultChecked className="rounded" />
<span></span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" className="rounded" />
<span></span>
</label>
</div>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { Network, Plus } from "lucide-react";
import { listSynergies, authorizeSynergy } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
const SYNERGY_TYPE_LABELS: Record<string, string> = {
customer: "客户协同",
talent: "人才协同",
funding: "融资协同",
supply_chain: "供应链协同",
tech: "技术协同",
};
export default function SynergiesPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = () => {
listSynergies()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
};
useEffect(() => { load(); }, []);
const handleAuthorize = async (id: string) => {
try {
await authorizeSynergy(id);
toast.success("授权成功");
load();
} catch {
toast.error("授权失败");
}
};
return (
<PageContainer title="协同中心" description="Portfolio 内部协同匹配 → 双方授权 → 效果追踪">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无协同机会" />
) : (
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge color="blue">{SYNERGY_TYPE_LABELS[item.type as string] ?? item.type}</Badge>
<h3 className="font-medium text-gray-900">{item.title as string}</h3>
</div>
<div className="flex items-center gap-2">
<Badge color={item.status === "completed" ? "green" : item.status === "authorized" ? "blue" : "gray"}>
{item.status as string}
</Badge>
{item.authorized === false && (
<button
onClick={() => handleAuthorize(item.id as string)}
className="rounded-md bg-gray-900 px-2 py-1 text-xs text-white hover:bg-gray-700"
>
</button>
)}
</div>
</div>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description as string}</p>}
{item.match_reason && <p className="mt-1 text-xs text-gray-400">{item.match_reason as string}</p>}
</div>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { Users } from "lucide-react";
import { listTalents } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
export default function TalentsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listTalents()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="人才引力场" description="人才流动预测 + 主动推荐 + 9-Box 矩阵">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无人才数据" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500">9-Box</th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{item.name as string}</td>
<td className="px-4 py-2 text-gray-600">{item.current_role as string}</td>
<td className="px-4 py-2 text-gray-600">{item.performance_rating as string ?? "-"}</td>
<td className="px-4 py-2 text-gray-600">{item.potential_rating as string ?? "-"}</td>
<td className="px-4 py-2"><Badge color="blue">{item.nine_box as string ?? "-"}</Badge></td>
<td className="px-4 py-2"><Badge color={item.status === "active" ? "green" : "gray"}>{item.status as string}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { BarChart3, Plus } from "lucide-react";
import { listTasks, updateTask } from "@/lib/api-v2";
import { PageContainer, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { toast } from "sonner";
const STATUS_LABELS: Record<string, string> = {
todo: "待办",
in_progress: "进行中",
done: "已完成",
cancelled: "已取消",
};
const PRIORITY_COLORS: Record<string, "red" | "amber" | "gray" | "blue"> = {
urgent: "red",
high: "amber",
medium: "gray",
low: "blue",
};
export default function TasksPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = () => {
listTasks()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
};
useEffect(() => { load(); }, []);
const handleStatusChange = async (id: string, status: string) => {
try {
await updateTask(id, { status });
toast.success("状态已更新");
load();
} catch {
toast.error("更新失败");
}
};
const columns = ["todo", "in_progress", "done"];
return (
<PageContainer
title="任务看板"
description="投后任务管理 — 来源:风险/月报/协同/手动"
actions={
<button className="flex items-center gap-1 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-700">
<Plus size={16} />
</button>
}
>
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无任务" />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{columns.map((col) => (
<div key={col} className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<h3 className="mb-2 text-sm font-medium text-gray-700">{STATUS_LABELS[col]}</h3>
<div className="space-y-2">
{items.filter((i) => i.status === col).map((item, i) => (
<div key={i} className="rounded-md border border-gray-200 bg-white p-3">
<div className="flex items-start justify-between">
<span className="text-sm font-medium text-gray-900">{item.title as string}</span>
<Badge color={PRIORITY_COLORS[item.priority as string] ?? "gray"}>
{item.priority as string}
</Badge>
</div>
{item.description && <p className="mt-1 text-xs text-gray-500">{item.description as string}</p>}
<select
value={item.status as string}
onChange={(e) => handleStatusChange(item.id as string, e.target.value)}
className="mt-2 w-full rounded border border-gray-200 px-2 py-1 text-xs"
>
{Object.entries(STATUS_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
))}
</div>
</div>
))}
</div>
)}
</PageContainer>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { Lightbulb } from "lucide-react";
import { listWeakSignals } from "@/lib/api-v2";
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
const SIGNAL_TYPE_LABELS: Record<string, string> = {
technical: "技术",
sentiment: "情绪",
org: "组织",
market: "市场",
};
export default function WeakSignalsPage() {
const [items, setItems] = useState<Record<string, any>[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
listWeakSignals()
.then((resp) => setItems((resp.data as Record<string, any>[]) ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, []);
return (
<PageContainer title="弱信号监控" description="技术/情绪/组织/市场四类弱信号采集与关联分析">
{isLoading ? (
<LoadingSpinner />
) : items.length === 0 ? (
<EmptyState description="暂无弱信号" />
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2">
<Badge color="blue">{SIGNAL_TYPE_LABELS[item.signal_type as string] ?? item.signal_type}</Badge>
</td>
<td className="px-4 py-2 text-gray-900 max-w-xs truncate">{item.content as string}</td>
<td className="px-4 py-2 text-gray-600">{((item.confidence as number) * 100).toFixed(0)}%</td>
<td className="px-4 py-2">
{item.risk_probability ? (
<Badge color={(item.risk_probability as number) > 0.6 ? "red" : (item.risk_probability as number) > 0.3 ? "amber" : "green"}>
{((item.risk_probability as number) * 100).toFixed(0)}%
</Badge>
) : "-"}
</td>
<td className="px-4 py-2">
<Badge color={item.status === "alerted" ? "red" : item.status === "correlated" ? "amber" : "gray"}>
{item.status as string}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</PageContainer>
);
}
+133 -5
View File
@@ -1,12 +1,140 @@
/** Admin 端首页 — 占位页面。 */
"use client";
import { useEffect, useState } from "react";
import { adminOverview, listTenants, listUsers, listAuditLogs } from "@/lib/api-v2";
import { PageContainer, Badge, Card } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
/**
* Admin 管理后台首页 — 系统概览 + 租户/用户/审计标签页。
*/
export default function AdminHomePage() {
const [overview, setOverview] = useState<Record<string, any> | null>(null);
const [tenants, setTenants] = useState<Record<string, any>[]>([]);
const [users, setUsers] = useState<Record<string, any>[]>([]);
const [auditLogs, setAuditLogs] = useState<Record<string, any>[]>([]);
const [tab, setTab] = useState<"overview" | "tenants" | "users" | "audit">("overview");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
Promise.all([adminOverview(), listTenants(), listUsers(), listAuditLogs()])
.then(([ov, t, u, a]) => {
setOverview(ov.data as Record<string, any>);
setTenants((t.data as Record<string, any>[]) ?? []);
setUsers((u.data as Record<string, any>[]) ?? []);
setAuditLogs((a.data as Record<string, any>[]) ?? []);
})
.catch(() => {
setOverview(null);
setTenants([]);
setUsers([]);
setAuditLogs([]);
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <LoadingSpinner />;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-foreground"></h1>
<EmptyState title="暂无数据" description="租户管理与权限配置功能开发中" />
</div>
<PageContainer title="管理后台" description="系统概览 · 租户管理 · 用户管理 · 审计日志">
<div className="flex gap-2 border-b border-gray-200">
{[
{ key: "overview", label: "概览" },
{ key: "tenants", label: "租户" },
{ key: "users", label: "用户" },
{ key: "audit", label: "审计日志" },
].map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key as typeof tab)}
className={`px-4 py-2 text-sm font-medium ${tab === t.key ? "border-b-2 border-amber-400 text-gray-900" : "text-gray-500"}`}
>
{t.label}
</button>
))}
</div>
{tab === "overview" && overview && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card>
<p className="text-sm text-gray-500"></p>
<p className="mt-1 text-2xl font-bold text-gray-900">{String(overview.tenant_count ?? 0)}</p>
</Card>
<Card>
<p className="text-sm text-gray-500"></p>
<p className="mt-1 text-2xl font-bold text-gray-900">{String(overview.user_count ?? 0)}</p>
</Card>
<Card>
<p className="text-sm text-gray-500"></p>
<div className="mt-2 space-y-1">
{Array.isArray(overview.tenants) && (overview.tenants as Record<string, any>[]).map((t, i) => (
<p key={i} className="text-sm text-gray-700">{String(t.name ?? "")}</p>
))}
</div>
</Card>
</div>
)}
{tab === "tenants" && (
tenants.length === 0 ? <EmptyState description="暂无租户" /> : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr><th className="px-4 py-2 text-left font-medium text-gray-500"></th><th className="px-4 py-2 text-left font-medium text-gray-500"></th></tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{tenants.map((t, i) => (
<tr key={i}><td className="px-4 py-2 text-gray-900">{String(t.name ?? "")}</td><td className="px-4 py-2 text-gray-500">{String(t.created_at ?? "")}</td></tr>
))}
</tbody>
</table>
</div>
)
)}
{tab === "users" && (
users.length === 0 ? <EmptyState description="暂无用户" /> : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr><th className="px-4 py-2 text-left font-medium text-gray-500"></th><th className="px-4 py-2 text-left font-medium text-gray-500"></th><th className="px-4 py-2 text-left font-medium text-gray-500"></th><th className="px-4 py-2 text-left font-medium text-gray-500"></th></tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{users.map((u, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{String(u.email ?? "")}</td>
<td className="px-4 py-2 text-gray-600">{String(u.name ?? "")}</td>
<td className="px-4 py-2"><Badge color="blue">{String(u.role ?? "")}</Badge></td>
<td className="px-4 py-2"><Badge color={u.is_active ? "green" : "red"}>{u.is_active ? "活跃" : "禁用"}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
{tab === "audit" && (
auditLogs.length === 0 ? <EmptyState description="暂无审计日志" /> : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr><th className="px-4 py-2 text-left font-medium text-gray-500"></th><th className="px-4 py-2 text-left font-medium text-gray-500"></th><th className="px-4 py-2 text-left font-medium text-gray-500"></th></tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{auditLogs.map((log, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-900">{String(log.action ?? "")}</td>
<td className="px-4 py-2 text-gray-600">{String(log.target_type ?? "")}: {String(log.target_id ?? "")}</td>
<td className="px-4 py-2 text-gray-500">{String(log.created_at ?? "")}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</PageContainer>
);
}
+11 -4
View File
@@ -1,12 +1,19 @@
/** 创始人端 AI 副驾驶页面 — 占位。 */
/** 创始人端 AI 副驾驶页面 — 融资规划 + 组织诊断 + 投资人沟通准备。 */
import { EmptyState } from "@/components/shared/EmptyState";
import { FinancingPlanner, OrgDiagnostic, InvestorComm } from "@/components/founder/CopilotTools";
export default function FounderCopilotPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-foreground">AI </h1>
<EmptyState title="AI 副驾驶" description="对话功能开发中" />
<div>
<h1 className="text-2xl font-bold text-foreground">AI </h1>
<p className="mt-1 text-sm text-muted-foreground"> · · </p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<FinancingPlanner />
<OrgDiagnostic />
<InvestorComm />
</div>
</div>
);
}
+87 -2
View File
@@ -1,15 +1,100 @@
/** 创始人端经营概览 — 占位页面。 */
"use client";
import { useEffect, useState } from "react";
import { founderOverview, founderHealth } from "@/lib/api-v2";
import { Card, Badge } from "@/components/shared/PageContainer";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
/**
* 创始人端经营概览 — 企业信息 + 健康度 + 最新月报。
*/
export default function FounderHomePage() {
const [overview, setOverview] = useState<Record<string, any> | null>(null);
const [health, setHealth] = useState<Record<string, any> | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
Promise.all([founderOverview(), founderHealth()])
.then(([ov, h]) => {
setOverview(ov.data as Record<string, any>);
setHealth(h.data as Record<string, any>);
})
.catch(() => {
setOverview(null);
setHealth(null);
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <LoadingSpinner />;
const company = overview?.company as Record<string, any> | undefined;
const healthScore = overview?.health_score as Record<string, any> | undefined;
const latestReport = overview?.latest_report as Record<string, any> | undefined;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<LoadingSpinner className="py-20" size={32} />
{!company && <EmptyState description="暂无关联企业" />}
{company && (
<>
<Card>
<h2 className="font-semibold text-gray-900">{String(company.name ?? "")}</h2>
<div className="mt-2 flex items-center gap-3 text-sm text-gray-600">
<span>{String(company.industry ?? "")}</span>
<span>{String(company.stage ?? "")}</span>
</div>
</Card>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<h3 className="text-sm font-medium text-gray-500"></h3>
{healthScore ? (
<div className="mt-2 space-y-2">
<div className="flex items-center gap-2">
<span className="text-3xl font-bold text-gray-900">{String(healthScore.total_score ?? "-")}</span>
{healthScore.trend && (
<Badge color={String(healthScore.trend) === "up" ? "green" : String(healthScore.trend) === "down" ? "red" : "gray"}>
{String(healthScore.trend)}
</Badge>
)}
</div>
{health && (
<div className="space-y-1 text-sm text-gray-600">
<p>{String(health.financial_score ?? "-")}</p>
<p>{String(health.operational_score ?? "-")}</p>
<p>AI {String(health.ai_commercial_score ?? "-")}</p>
<p>AI {String(health.ai_cost_score ?? "-")}</p>
</div>
)}
</div>
) : (
<p className="mt-2 text-sm text-gray-400"></p>
)}
</Card>
<Card>
<h3 className="text-sm font-medium text-gray-500"></h3>
{latestReport ? (
<div className="mt-2 space-y-2">
<p className="text-lg font-medium text-gray-900">{String(latestReport.period ?? "")}</p>
<Badge color={String(latestReport.status) === "submitted" ? "green" : "amber"}>
{String(latestReport.status ?? "")}
</Badge>
</div>
) : (
<p className="mt-2 text-sm text-gray-400"></p>
)}
</Card>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,114 @@
"use client";
import { Target, ArrowRight, DollarSign, Users, Trophy } from "lucide-react";
/** 客户获取方案卡片 — 展示切入角度/决策链/定价/竞争分析。 */
export function PlanCard({ plan }: { plan: any }) {
const statusColors: Record<string, string> = {
planned: "bg-blue-100 text-blue-700",
executing: "bg-amber-100 text-amber-700",
completed: "bg-emerald-100 text-emerald-700",
failed: "bg-rose-100 text-rose-700",
};
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4 shadow-sm">
{/* 头部 */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Target size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium">{plan.target_customer || "未指定目标客户"}</h3>
</div>
<span className={`rounded px-2 py-0.5 text-xs ${statusColors[plan.execution_status] || "bg-muted"}`}>
{plan.execution_status}
</span>
</div>
{/* 切入角度 */}
{plan.entry_angle && (
<div className="mt-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<ArrowRight size={12} />
<span></span>
</div>
<p className="mt-1 text-sm">{plan.entry_angle}</p>
</div>
)}
{/* 定价策略 */}
{plan.pricing_strategy && (
<div className="mt-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<DollarSign size={12} />
<span></span>
</div>
<p className="mt-1 text-sm">{plan.pricing_strategy}</p>
</div>
)}
{/* 决策链 */}
{plan.decision_chain && Array.isArray(plan.decision_chain) && plan.decision_chain.length > 0 && (
<div className="mt-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Users size={12} />
<span></span>
</div>
<div className="mt-1 flex flex-wrap gap-2">
{plan.decision_chain.map((node: any, i: number) => (
<span key={i} className="rounded-md bg-muted px-2 py-1 text-xs">
{node.role || "角色"}
{node.influence && ` · ${node.influence}`}
</span>
))}
</div>
</div>
)}
{/* 竞争分析 */}
{plan.competitive_analysis && (
<div className="mt-3">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Trophy size={12} />
<span></span>
</div>
<div className="mt-1 grid grid-cols-2 gap-2 text-xs">
{plan.competitive_analysis.strengths && (
<div>
<div className="font-medium text-emerald-600"></div>
<ul className="ml-3 list-disc">
{plan.competitive_analysis.strengths.map((s: string, i: number) => (
<li key={i}>{s}</li>
))}
</ul>
</div>
)}
{plan.competitive_analysis.weaknesses && (
<div>
<div className="font-medium text-rose-600"></div>
<ul className="ml-3 list-disc">
{plan.competitive_analysis.weaknesses.map((w: string, i: number) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* LP 资源 */}
{plan.lp_resources && Array.isArray(plan.lp_resources) && plan.lp_resources.length > 0 && (
<div className="mt-3">
<div className="text-xs text-muted-foreground"> LP </div>
<div className="mt-1 flex flex-wrap gap-1">
{plan.lp_resources.map((r: string, i: number) => (
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
{r}
</span>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { TrendingUp, TrendingDown, Minus, AlertCircle } from "lucide-react";
/** 预测趋势图组件 — 含置信区间。 */
export function ForecastChart({ companyId }: { companyId?: string }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const path = companyId
? `/dashboard/forecasts?company_id=${companyId}&months_ahead=3`
: "/dashboard/forecasts?months_ahead=3";
apiFetch<any>(path)
.then((res) => setData(res.data))
.catch(() => {})
.finally(() => setLoading(false));
}, [companyId]);
if (loading) return <LoadingSpinner />;
if (!data) return <EmptyState title="暂无预测数据" />;
const predictions: number[] = data.predictions || [];
const trendDir = data.trend_direction || "stable";
const TrendIcon = trendDir === "up" ? TrendingUp : trendDir === "down" ? TrendingDown : Minus;
const trendColor = trendDir === "up" ? "text-emerald-600" : trendDir === "down" ? "text-rose-600" : "text-muted-foreground";
const anomalies = data.anomalies || {};
const anomalyKeys = Object.keys(anomalies);
const width = 500;
const height = 200;
const padding = { top: 20, right: 20, bottom: 30, left: 40 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const allValues = predictions.length > 0 ? predictions : [];
const maxVal = 100;
const minVal = 0;
const yScale = (v: number) => chartH - ((v - minVal) / (maxVal - minVal)) * chartH;
const xStep = predictions.length > 1 ? chartW / (predictions.length - 1) : 0;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">: {(data.confidence * 100).toFixed(0)}%</span>
<TrendIcon size={16} className={trendColor} />
</div>
</div>
{predictions.length > 0 ? (
<svg width={width} height={height} role="img" aria-label="健康度预测趋势图">
{[0, 25, 50, 75, 100].map((v) => (
<g key={v}>
<line
x1={padding.left}
y1={padding.top + yScale(v)}
x2={width - padding.right}
y2={padding.top + yScale(v)}
stroke="var(--border)"
strokeWidth={1}
strokeDasharray={v === 0 ? "none" : "2,2"}
/>
<text
x={padding.left - 8}
y={padding.top + yScale(v) + 4}
textAnchor="end"
className="text-[10px] fill-muted-foreground"
>
{v}
</text>
</g>
))}
{/* 预测折线 */}
<path
d={predictions.map((p, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(p)}`).join(" ")}
fill="none"
stroke="var(--investor-primary)"
strokeWidth={2}
strokeDasharray="4,2"
/>
{/* 数据点 */}
{predictions.map((p, i) => (
<g key={i}>
<circle cx={padding.left + i * xStep} cy={padding.top + yScale(p)} r={4} fill="var(--investor-primary)" />
<text
x={padding.left + i * xStep}
y={padding.top + yScale(p) - 10}
textAnchor="middle"
className="text-[10px] fill-foreground font-medium"
>
{p.toFixed(1)}
</text>
<text
x={padding.left + i * xStep}
y={height - padding.bottom + 16}
textAnchor="middle"
className="text-[10px] fill-muted-foreground"
>
+{i + 1}
</text>
</g>
))}
</svg>
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
2
</div>
)}
{/* 异常检测 */}
{anomalyKeys.length > 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2">
<div className="flex items-center gap-1 text-xs font-medium text-amber-700">
<AlertCircle size={12} />
</div>
<div className="mt-1 flex flex-wrap gap-1">
{anomalyKeys.map((key) => (
<span key={key} className="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700">
{key} (: {anomalies[key].join(", ")})
</span>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,191 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { DIMENSIONS_14 } from "@/components/health/HealthRadar";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
/** 健康度热力图 — 企业 × 维度评分矩阵。 */
export function HealthHeatmap() {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
apiFetch<any[]>("/dashboard/heatmap")
.then((res) => setData((res.data as any[]) || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <LoadingSpinner />;
if (!data.length) return <EmptyState title="暂无热力图数据" />;
const dims = DIMENSIONS_14;
/** 根据分数返回背景色。 */
const getColor = (score: number | null | undefined) => {
if (score == null) return "bg-muted";
if (score >= 75) return "bg-emerald-200";
if (score >= 60) return "bg-emerald-100";
if (score >= 50) return "bg-amber-100";
if (score >= 40) return "bg-amber-200";
return "bg-rose-200";
};
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
<th className="sticky left-0 z-10 bg-white px-2 py-1 text-left font-medium"></th>
<th className="px-2 py-1 text-center font-medium"></th>
{dims.map((d) => (
<th key={d.key} className="px-1 py-1 text-center font-medium" title={d.label}>
{d.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.company_id} className="border-t border-[var(--border)]">
<td className="sticky left-0 z-10 bg-white px-2 py-1 font-medium">
{row.company_name}
</td>
<td className="px-2 py-1 text-center font-bold">
{row.total_score != null ? row.total_score.toFixed(0) : "-"}
</td>
{dims.map((d) => {
const val = row.scores?.[d.key];
return (
<td key={d.key} className="px-1 py-1 text-center">
<div
className={`mx-auto flex h-8 w-8 items-center justify-center rounded ${getColor(val)}`}
title={`${row.company_name} - ${d.label}: ${val ?? "N/A"}`}
>
{val != null ? val.toFixed(0) : "-"}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** 健康度趋势对比图 — 按月汇总评分变化折线图。 */
export function HealthTrends({ companyId }: { companyId?: string }) {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const path = companyId
? `/dashboard/trends?company_id=${companyId}&months=6`
: "/dashboard/trends?months=6";
apiFetch<any[]>(path)
.then((res) => setData((res.data as any[]) || []))
.catch(() => {})
.finally(() => setLoading(false));
}, [companyId]);
if (loading) return <LoadingSpinner />;
if (!data.length) return <EmptyState title="暂无趋势数据" />;
const width = 600;
const height = 240;
const padding = { top: 20, right: 20, bottom: 40, left: 40 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const periods = data.map((d) => d.period);
const scores = data.map((d) => d.avg_score);
const maxScore = 100;
const minScore = 0;
const xStep = periods.length > 1 ? chartW / (periods.length - 1) : 0;
const yScale = (val: number) => chartH - ((val - minScore) / (maxScore - minScore)) * chartH;
const linePath = scores
.map((s, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(s)}`)
.join(" ");
const prevScore = scores.length > 1 ? scores[scores.length - 2] : null;
const latestScore = scores[scores.length - 1];
const trendDir = prevScore != null
? latestScore > prevScore + 2 ? "up" : latestScore < prevScore - 2 ? "down" : "stable"
: "stable";
const TrendIcon = trendDir === "up" ? TrendingUp : trendDir === "down" ? TrendingDown : Minus;
const trendColor = trendDir === "up" ? "text-emerald-600" : trendDir === "down" ? "text-rose-600" : "text-muted-foreground";
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-[var(--investor-primary)]">
{latestScore?.toFixed(1)}
</span>
<TrendIcon size={18} className={trendColor} />
</div>
</div>
<svg width={width} height={height} role="img" aria-label="健康度趋势图">
{/* Y 轴刻度 */}
{[0, 25, 50, 75, 100].map((v) => (
<g key={v}>
<line
x1={padding.left}
y1={padding.top + yScale(v)}
x2={width - padding.right}
y2={padding.top + yScale(v)}
stroke="var(--border)"
strokeWidth={1}
strokeDasharray={v === 0 ? "none" : "2,2"}
/>
<text
x={padding.left - 8}
y={padding.top + yScale(v) + 4}
textAnchor="end"
className="text-[10px] fill-muted-foreground"
>
{v}
</text>
</g>
))}
{/* 折线 */}
<path d={linePath} fill="none" stroke="var(--investor-primary)" strokeWidth={2} />
{/* 数据点 */}
{scores.map((s, i) => (
<g key={i}>
<circle
cx={padding.left + i * xStep}
cy={padding.top + yScale(s)}
r={4}
fill="var(--investor-primary)"
/>
<text
x={padding.left + i * xStep}
y={padding.top + yScale(s) - 10}
textAnchor="middle"
className="text-[10px] fill-foreground font-medium"
>
{s.toFixed(1)}
</text>
<text
x={padding.left + i * xStep}
y={height - padding.bottom + 16}
textAnchor="middle"
className="text-[10px] fill-muted-foreground"
>
{periods[i]}
</text>
</g>
))}
</svg>
</div>
);
}
@@ -0,0 +1,252 @@
"use client";
import { useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { DollarSign, TrendingUp, Users, MessageSquare } from "lucide-react";
/** 融资规划组件 — 节奏/估值/投资人画像。 */
export function FinancingPlanner() {
const [companyData, setCompanyData] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
async function handleGenerate() {
if (!companyData.trim()) return;
setLoading(true);
try {
const res = await apiFetch<any>("/founder/financing-plan", {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
setResult(res.data);
} catch {
// ignore
} finally {
setLoading(false);
}
}
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<DollarSign size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述企业当前融资情况、营收、阶段..."
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
/>
<button
onClick={handleGenerate}
disabled={loading || !companyData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{loading ? "生成中..." : "生成融资规划"}
</button>
{loading && <div className="mt-2"><LoadingSpinner /></div>}
{result && (
<div className="mt-3 space-y-2 text-sm">
{result.round && <div><span className="text-muted-foreground"></span>{result.round}</div>}
{result.target_amount && <div><span className="text-muted-foreground"></span>{result.target_amount}</div>}
{result.valuation_range && <div><span className="text-muted-foreground"></span>{result.valuation_range}</div>}
{result.timeline && <div><span className="text-muted-foreground"></span>{result.timeline}</div>}
{result.target_investors && Array.isArray(result.target_investors) && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 flex flex-wrap gap-1">
{result.target_investors.map((inv: string, i: number) => (
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">{inv}</span>
))}
</div>
</div>
)}
{result.key_metrics && Array.isArray(result.key_metrics) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{result.key_metrics.map((m: string, i: number) => <li key={i}>{m}</li>)}
</ul>
</div>
)}
</div>
)}
</div>
);
}
/** 组织诊断组件 — 团队结构/关键岗位风险/人才缺口。 */
export function OrgDiagnostic() {
const [teamData, setTeamData] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
async function handleGenerate() {
if (!teamData.trim()) return;
setLoading(true);
try {
const res = await apiFetch<any>("/founder/org-diagnostic", {
method: "POST",
body: JSON.stringify({ team_data: teamData }),
});
setResult(res.data);
} catch {
// ignore
} finally {
setLoading(false);
}
}
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Users size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述团队规模、关键岗位、流失情况..."
value={teamData}
onChange={(e) => setTeamData(e.target.value)}
/>
<button
onClick={handleGenerate}
disabled={loading || !teamData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{loading ? "诊断中..." : "开始组织诊断"}
</button>
{loading && <div className="mt-2"><LoadingSpinner /></div>}
{result && (
<div className="mt-3 space-y-2 text-sm">
{result.structure_assessment && (
<div><span className="text-muted-foreground"></span>{result.structure_assessment}</div>
)}
{result.key_role_risks && Array.isArray(result.key_role_risks) && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 space-y-1">
{result.key_role_risks.map((r: any, i: number) => (
<div key={i} className="flex items-center justify-between rounded-md border border-[var(--border)] px-2 py-1 text-xs">
<span>{r.role}</span>
<span className={r.severity === "high" ? "text-rose-600" : r.severity === "medium" ? "text-amber-600" : "text-muted-foreground"}>
{r.risk}
</span>
</div>
))}
</div>
</div>
)}
{result.talent_gaps && Array.isArray(result.talent_gaps) && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 flex flex-wrap gap-1">
{result.talent_gaps.map((g: string, i: number) => (
<span key={i} className="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700">{g}</span>
))}
</div>
</div>
)}
{result.recommendations && Array.isArray(result.recommendations) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{result.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
)}
</div>
);
}
/** 投资人沟通准备组件 — 董事会材料/投资人问答。 */
export function InvestorComm() {
const [boardContext, setBoardContext] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
async function handleGenerate() {
if (!boardContext.trim()) return;
setLoading(true);
try {
const res = await apiFetch<any>("/founder/investor-comm-prep", {
method: "POST",
body: JSON.stringify({ board_context: boardContext }),
});
setResult(res.data);
} catch {
// ignore
} finally {
setLoading(false);
}
}
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<MessageSquare size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述董事会/投资人会议背景、需要汇报的内容..."
value={boardContext}
onChange={(e) => setBoardContext(e.target.value)}
/>
<button
onClick={handleGenerate}
disabled={loading || !boardContext.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{loading ? "准备中..." : "生成沟通材料"}
</button>
{loading && <div className="mt-2"><LoadingSpinner /></div>}
{result && (
<div className="mt-3 space-y-2 text-sm">
{result.board_material_outline && (
<div>
<span className="text-muted-foreground"></span>
<p className="mt-1 whitespace-pre-wrap">{result.board_material_outline}</p>
</div>
)}
{result.anticipated_questions && Array.isArray(result.anticipated_questions) && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 space-y-1">
{result.anticipated_questions.map((q: any, i: number) => (
<div key={i} className="rounded-md border border-[var(--border)] px-2 py-1 text-xs">
<div className="font-medium">Q: {q.question}</div>
<div className="mt-1 text-muted-foreground">A: {q.suggested_answer}</div>
</div>
))}
</div>
</div>
)}
{result.key_updates && Array.isArray(result.key_updates) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{result.key_updates.map((u: string, i: number) => <li key={i}>{u}</li>)}
</ul>
</div>
)}
{result.asks && Array.isArray(result.asks) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{result.asks.map((a: string, i: number) => <li key={i}>{a}</li>)}
</ul>
</div>
)}
</div>
)}
</div>
);
}
+112 -49
View File
@@ -1,39 +1,55 @@
"use client";
import { useMemo } from "react";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
/** 健康度雷达图组件 — 四维评分雷达图。 */
/** 健康度雷达图组件 — 支持 4/9/14 维度。 */
interface HealthRadarProps {
scores: {
financial: number;
operational: number;
ai_commercial: number;
ai_cost: number;
};
scores: Record<string, number | null | undefined>;
dimensions: { key: string; label: string }[];
size?: number;
}
const LABELS: Record<string, string> = {
financial: "财务",
operational: "经营",
ai_commercial: "AI 商业化",
ai_cost: "AI 成本",
};
/** 14 维度定义。 */
export const DIMENSIONS_14 = [
{ key: "financial_score", label: "财务" },
{ key: "operational_score", label: "经营" },
{ key: "ai_commercial_score", label: "AI商业化" },
{ key: "ai_cost_score", label: "AI成本" },
{ key: "org_talent_score", label: "组织人才" },
{ key: "product_tech_score", label: "产品技术" },
{ key: "market_compete_score", label: "市场竞争" },
{ key: "governance_score", label: "治理合规" },
{ key: "financing_score", label: "融资资本" },
{ key: "synergy_score", label: "协同赋能" },
{ key: "ai_model_product_score", label: "AI模型产品" },
{ key: "data_compliance_score", label: "数据合规" },
{ key: "team_tech_score", label: "团队技术" },
{ key: "customer_success_score", label: "客户成功" },
];
/**
* 健康度雷达图(纯 SVG,无额外依赖)。
*/
export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
const center = size / 2;
const radius = size / 2 - 40;
const axes = Object.keys(LABELS);
const angleStep = (Math.PI * 2) / axes.length;
/** 9 维度定义(T2.9)。 */
export const DIMENSIONS_9 = DIMENSIONS_14.slice(0, 9);
/** 基础 4 维度定义。 */
export const DIMENSIONS_4 = DIMENSIONS_14.slice(0, 4);
/** 健康度雷达图(纯 SVG,支持动态维度数)。 */
export function HealthRadar({ scores, dimensions, size = 280 }: HealthRadarProps) {
const validDims = dimensions.filter((d) => scores[d.key] != null);
const { gridLevels, axisLines, dataPoints, labelPositions } = useMemo(() => {
const center = size / 2;
const radius = size / 2 - 50;
const numDims = validDims.length;
if (numDims < 3) return { gridLevels: [], axisLines: [], dataPoints: [], labelPositions: [] };
const angleStep = (Math.PI * 2) / numDims;
const levels = [0.25, 0.5, 0.75, 1.0];
const grid = levels.map((level) =>
axes.map((_, i) => {
validDims.map((_, i) => {
const angle = i * angleStep - Math.PI / 2;
return {
x: center + Math.cos(angle) * radius * level,
@@ -42,7 +58,7 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
})
);
const axis = axes.map((_, i) => {
const axis = validDims.map((_, i) => {
const angle = i * angleStep - Math.PI / 2;
return {
x: center + Math.cos(angle) * radius,
@@ -50,8 +66,8 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
};
});
const values = [scores.financial, scores.operational, scores.ai_commercial, scores.ai_cost];
const points = values.map((val, i) => {
const points = validDims.map((dim, i) => {
const val = scores[dim.key] ?? 0;
const angle = i * angleStep - Math.PI / 2;
const r = (val / 100) * radius;
return {
@@ -60,23 +76,30 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
};
});
const labels = axes.map((key, i) => {
const labels = validDims.map((dim, i) => {
const angle = i * angleStep - Math.PI / 2;
return {
x: center + Math.cos(angle) * (radius + 20),
y: center + Math.sin(angle) * (radius + 20),
label: LABELS[key],
x: center + Math.cos(angle) * (radius + 25),
y: center + Math.sin(angle) * (radius + 25),
label: dim.label,
};
});
return { gridLevels: grid, axisLines: axis, dataPoints: points, labelPositions: labels };
}, [scores, center, radius, angleStep]);
}, [scores, validDims, size]);
if (validDims.length < 3) {
return (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
</div>
);
}
const polygonPoints = dataPoints.map((p) => `${p.x},${p.y}`).join(" ");
return (
<svg width={size} height={size} role="img" aria-label="健康度雷达图">
{/* 网格 */}
{gridLevels.map((level, idx) => (
<polygon
key={idx}
@@ -87,13 +110,11 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
strokeWidth={1}
/>
))}
{/* 轴线 */}
{axisLines.map((line, idx) => (
<line
key={idx}
x1={center}
y1={center}
x1={size / 2}
y1={size / 2}
x2={line.x}
y2={line.y}
stroke="currentColor"
@@ -101,8 +122,6 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
strokeWidth={1}
/>
))}
{/* 数据区域 */}
<polygon
points={polygonPoints}
fill="var(--investor-primary)"
@@ -110,19 +129,9 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
stroke="var(--investor-primary)"
strokeWidth={2}
/>
{/* 数据点 */}
{dataPoints.map((p, idx) => (
<circle
key={idx}
cx={p.x}
cy={p.y}
r={4}
fill="var(--investor-primary)"
/>
<circle key={idx} cx={p.x} cy={p.y} r={3} fill="var(--investor-primary)" />
))}
{/* 标签 */}
{labelPositions.map((lp, idx) => (
<text
key={idx}
@@ -130,7 +139,7 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
y={lp.y}
textAnchor="middle"
dominantBaseline="middle"
className="text-xs fill-muted-foreground"
className="text-[10px] fill-muted-foreground"
>
{lp.label}
</text>
@@ -138,3 +147,57 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
</svg>
);
}
/** 健康度维度详情面板 — 评分依据/扣分项/建议。 */
export function HealthDimensionDetail({
scores,
dimensions,
}: {
scores: Record<string, number | null | undefined>;
dimensions: { key: string; label: string; description: string }[];
}) {
return (
<div className="space-y-2">
{dimensions.map((dim) => {
const score = scores[dim.key];
if (score == null) return null;
const color =
score >= 75 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
const TrendIcon = score >= 70 ? TrendingUp : score < 40 ? TrendingDown : Minus;
return (
<div
key={dim.key}
className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2"
>
<div>
<div className="text-sm font-medium">{dim.label}</div>
<div className="text-xs text-muted-foreground">{dim.description}</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-lg font-bold ${color}`}>{score.toFixed(0)}</span>
<TrendIcon size={16} className={color} />
</div>
</div>
);
})}
</div>
);
}
/** 14 维度详情定义(含描述)。 */
export const DIMENSIONS_14_DETAIL = [
{ key: "financial_score", label: "财务", description: "现金跑道、营收增长、烧钱率" },
{ key: "operational_score", label: "经营", description: "团队规模、关键指标达成" },
{ key: "ai_commercial_score", label: "AI商业化", description: "AI 相关指标商业化" },
{ key: "ai_cost_score", label: "AI成本", description: "AI 推理成本效率" },
{ key: "org_talent_score", label: "组织人才", description: "流失率、关键岗位" },
{ key: "product_tech_score", label: "产品技术", description: "迭代频率、技术指标" },
{ key: "market_compete_score", label: "市场竞争", description: "市场份额、客户增长" },
{ key: "governance_score", label: "治理合规", description: "董事会、合规事件" },
{ key: "financing_score", label: "融资资本", description: "跑道、融资进度" },
{ key: "synergy_score", label: "协同赋能", description: "Portfolio 协同" },
{ key: "ai_model_product_score", label: "AI模型产品", description: "模型精度、数据质量" },
{ key: "data_compliance_score", label: "数据合规", description: "数据合规审计" },
{ key: "team_tech_score", label: "团队技术", description: "技术负责人、专利" },
{ key: "customer_success_score", label: "客户成功", description: "留存率、NPS" },
];
@@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { FileText, Printer, Download } from "lucide-react";
/** 报告预览组件 — 浏览器打印优化。 */
export function ReportPreview({ report, companyName }: { report: any; companyName: string }) {
if (!report) return null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between no-print">
<h3 className="text-sm font-medium"></h3>
<button
onClick={() => window.print()}
className="flex items-center gap-1 rounded-md border border-[var(--border)] px-3 py-1.5 text-sm hover:bg-muted"
>
<Printer size={14} />
</button>
</div>
<div className="rounded-lg border border-[var(--border)] bg-white p-6 print:border-0 print:shadow-none">
{/* 封面 */}
<div className="mb-6 border-b pb-4 no-print">
<h1 className="text-xl font-bold">{companyName} </h1>
<p className="mt-1 text-sm text-muted-foreground">
{new Date().toLocaleDateString("zh-CN")}
</p>
</div>
{/* 报告内容 */}
{report.executive_summary && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-sm whitespace-pre-wrap">{report.executive_summary}</p>
</section>
)}
{report.financial_performance && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-sm whitespace-pre-wrap">{report.financial_performance}</p>
</section>
)}
{report.operational_highlights && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-sm whitespace-pre-wrap">{report.operational_highlights}</p>
</section>
)}
{report.risk_assessment && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-sm whitespace-pre-wrap">{report.risk_assessment}</p>
</section>
)}
{report.recommendations && Array.isArray(report.recommendations) && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<ul className="ml-4 list-disc text-sm">
{report.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
</ul>
</section>
)}
{report.next_quarter_focus && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-sm whitespace-pre-wrap">{report.next_quarter_focus}</p>
</section>
)}
{/* 年度报告字段 */}
{report.year_in_review && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-sm whitespace-pre-wrap">{report.year_in_review}</p>
</section>
)}
{report.key_achievements && Array.isArray(report.key_achievements) && (
<section className="mb-4">
<h2 className="text-sm font-semibold"></h2>
<ul className="ml-4 list-disc text-sm">
{report.key_achievements.map((a: string, i: number) => <li key={i}>{a}</li>)}
</ul>
</section>
)}
</div>
</div>
);
}
/** 报告模板选择页。 */
export function ReportTemplateSelector({ onGenerate }: { onGenerate: (type: string, data: string) => void }) {
const [template, setTemplate] = useState("quarterly");
const [companyData, setCompanyData] = useState("");
const [generating, setGenerating] = useState(false);
const templates = [
{ id: "quarterly", name: "季度报告", desc: "包含财务表现、运营亮点、风险评估、下季度建议" },
{ id: "annual", name: "年度报告", desc: "包含年度回顾、关键成就、Alpha 归因、下年度计划" },
];
async function handleGenerate() {
if (!companyData.trim()) return;
setGenerating(true);
try {
onGenerate(template, companyData);
} finally {
setGenerating(false);
}
}
return (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
{templates.map((t) => (
<button
key={t.id}
onClick={() => setTemplate(t.id)}
className={`rounded-lg border p-4 text-left transition-all ${
template === t.id
? "border-[var(--investor-primary)] bg-[var(--investor-primary)]/5"
: "border-[var(--border)] hover:border-[var(--investor-primary)]/50"
}`}
>
<div className="flex items-center gap-2">
<FileText size={16} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium">{t.name}</h3>
</div>
<p className="mt-1 text-xs text-muted-foreground">{t.desc}</p>
</button>
))}
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<textarea
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={5}
placeholder="输入企业经营数据、关键指标、风险事件等..."
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
/>
</div>
<button
onClick={handleGenerate}
disabled={generating || !companyData.trim()}
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? <LoadingSpinner /> : <Download size={14} />}
{generating ? "生成中..." : "生成报告"}
</button>
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import { Clock, TrendingUp } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
/**
* 月报提交及时性面板 — 延迟热力图 + 数据质量评分卡。
*/
interface TimelinessItem {
company_id: string;
company_name: string;
period_year: number;
period_month: number;
submitted_at: string;
delay_days: number;
quality_score: number;
status: string;
}
export function TimelinessPanel({ companyId }: { companyId?: string }) {
const [items, setItems] = useState<TimelinessItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const path = companyId
? `/reports/timeliness?company_id=${companyId}`
: "/reports/timeliness";
apiFetch<TimelinessItem[]>(path)
.then((resp) => setItems(resp.data ?? []))
.catch(() => setItems([]))
.finally(() => setIsLoading(false));
}, [companyId]);
if (isLoading) return <LoadingSpinner />;
if (!items.length) return <EmptyState description="暂无月报提交记录" />;
const avgDelay = Math.round(
items.reduce((s, i) => s + i.delay_days, 0) / items.length,
);
const avgQuality = Math.round(
items.reduce((s, i) => s + i.quality_score, 0) / items.length,
);
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<Clock className="h-4 w-4" />
</div>
<div className="mt-1 text-2xl font-semibold text-gray-900">
{avgDelay} <span className="text-sm text-gray-400"></span>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<TrendingUp className="h-4 w-4" />
</div>
<div className="mt-1 text-2xl font-semibold text-gray-900">
{avgQuality} <span className="text-sm text-gray-400">/ 100</span>
</div>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
<th className="px-4 py-2 text-left font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{items.map((item, idx) => (
<tr key={idx}>
<td className="px-4 py-2 text-gray-900">{item.company_name}</td>
<td className="px-4 py-2 text-gray-600">
{item.period_year}-{String(item.period_month).padStart(2, "0")}
</td>
<td className="px-4 py-2">
<span
className={
item.delay_days === 0
? "text-emerald-600"
: item.delay_days <= 3
? "text-amber-600"
: "text-rose-600"
}
>
{item.delay_days}
</span>
</td>
<td className="px-4 py-2">
<span
className={
item.quality_score >= 80
? "text-emerald-600"
: item.quality_score >= 50
? "text-amber-600"
: "text-rose-600"
}
>
{item.quality_score}
</span>
</td>
<td className="px-4 py-2 text-gray-500">{item.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
/**
* 路由守卫 — 未登录时重定向到登录页。
*
* 包裹投资人端和创始人端的受保护页面。
*/
export function AuthGuard({ children }: { children: ReactNode }) {
const { token, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !token) {
router.replace("/login");
}
}, [isLoading, token, router]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!token) {
return null;
}
return <>{children}</>;
}
@@ -0,0 +1,88 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Upload, File as FileIcon, X, Loader2 } from "lucide-react";
/** 文件上传组件 — 支持拖拽 + 点击上传。 */
export function FileUploader({ onParsed }: { onParsed: (result: any) => void }) {
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState<File | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const allowedTypes = [".xlsx", ".xls", ".pdf", ".txt", ".md", ".csv"];
const handleFile = useCallback(async (f: File) => {
const ext = f.name.match(/\.[^.]+$/)?.[0]?.toLowerCase() || "";
if (!allowedTypes.includes(ext)) {
return;
}
setFile(f);
setUploading(true);
try {
const formData = new FormData();
formData.append("file", f);
const token = localStorage.getItem("token");
const resp = await fetch("/api/v1/reports/upload", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
const data = await resp.json();
onParsed(data.data);
} catch {
// ignore
} finally {
setUploading(false);
}
}, [onParsed]);
return (
<div
className="rounded-lg border-2 border-dashed border-[var(--border)] p-6 text-center transition-colors"
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
}}
style={dragging ? { borderColor: "var(--investor-primary)", background: "var(--investor-primary)/5" } : {}}
>
{file ? (
<div className="flex items-center justify-between rounded-md border border-[var(--border)] bg-white px-3 py-2">
<div className="flex items-center gap-2">
<FileIcon size={16} className="text-[var(--investor-primary)]" />
<span className="text-sm">{file.name}</span>
</div>
<div className="flex items-center gap-2">
{uploading && <Loader2 size={14} className="animate-spin text-muted-foreground" />}
<button
onClick={() => { setFile(null); onParsed(null); }}
className="text-muted-foreground hover:text-foreground"
aria-label="移除文件"
>
<X size={14} />
</button>
</div>
</div>
) : (
<button
onClick={() => inputRef.current?.click()}
className="flex flex-col items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<Upload size={24} />
<span></span>
<span className="text-xs"> .xlsx .pdf .txt .csv .md 10MB</span>
</button>
)}
<input
ref={inputRef}
type="file"
accept=".xlsx,.xls,.pdf,.txt,.md,.csv"
className="hidden"
onChange={(e) => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }}
/>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { apiFetch } from "@/lib/api";
import { Search, FileText, Loader2 } from "lucide-react";
/** 语义搜索组件 — 搜索知识库中的月报/报告片段。 */
export function KnowledgeSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
async function handleSearch() {
if (!query.trim()) return;
setLoading(true);
setSearched(true);
try {
const res = await apiFetch<any[]>(`/knowledge/search?q=${encodeURIComponent(query)}&top_k=5`);
setResults((res.data as any[]) || []);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
className="w-full rounded-md border border-[var(--border)] py-2 pl-9 pr-3 text-sm"
placeholder="语义搜索知识库..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
<button
onClick={handleSearch}
disabled={loading || !query.trim()}
className="flex items-center gap-1 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
</button>
</div>
{searched && !loading && results.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground"></div>
)}
{results.length > 0 && (
<div className="space-y-2">
{results.map((r) => (
<div key={r.id} className="rounded-lg border border-[var(--border)] bg-white p-3">
<div className="flex items-center gap-2">
<FileText size={14} className="text-[var(--investor-primary)]" />
<span className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
{r.source_type}
</span>
</div>
<p className="mt-2 text-sm text-foreground">{r.content}</p>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,79 @@
/** 通用列表页面布局组件 — 标题 + 描述 + 操作区 + 内容区。 */
import { type ReactNode } from "react";
/**
* 通用页面容器。
* @param title - 页面标题
* @param description - 页面描述
* @param actions - 操作按钮区
* @param children - 内容区
*/
export function PageContainer({
title,
description,
actions,
children,
}: {
title: string;
description?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
{description && (
<p className="mt-1 text-sm text-gray-500">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
{children}
</div>
);
}
/**
* 简单卡片。
*/
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<div className={`rounded-lg border border-gray-200 bg-white p-4 ${className}`}>
{children}
</div>
);
}
/**
* 标签徽章。
*/
export function Badge({ children, color = "gray" }: { children: ReactNode; color?: "gray" | "green" | "amber" | "red" | "blue" }) {
const colors: Record<string, string> = {
gray: "bg-gray-100 text-gray-700",
green: "bg-emerald-100 text-emerald-700",
amber: "bg-amber-100 text-amber-700",
red: "bg-rose-100 text-rose-700",
blue: "bg-blue-100 text-blue-700",
};
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colors[color]}`}>
{children}
</span>
);
}
/**
* 空表格行占位。
*/
export function TableEmpty({ colSpan, message = "暂无数据" }: { colSpan: number; message?: string }) {
return (
<tr>
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-gray-400">
{message}
</td>
</tr>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { AlertTriangle, GitBranch, TrendingDown } from "lucide-react";
/** 约束点面板 — TOC 约束点识别。 */
export function ConstraintPanel() {
const [companyData, setCompanyData] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
async function handleAnalyze() {
if (!companyData.trim()) return;
setLoading(true);
try {
const res = await apiFetch<any>("/advanced-analysis/constraints", {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
setResult(res.data);
} catch {
// ignore
} finally {
setLoading(false);
}
}
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-amber-500" />
<h3 className="text-sm font-medium">TOC </h3>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述企业当前运营数据和瓶颈..."
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
/>
<button
onClick={handleAnalyze}
disabled={loading || !companyData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{loading ? "分析中..." : "识别约束点"}
</button>
{loading && <div className="mt-2"><LoadingSpinner /></div>}
{result && (
<div className="mt-3 space-y-2 text-sm">
{result.constraint && <div><span className="text-muted-foreground"></span>{result.constraint}</div>}
{result.sensitivity && <div><span className="text-muted-foreground"></span>{result.sensitivity}</div>}
{result.improvement_space && <div><span className="text-muted-foreground"></span>{result.improvement_space}</div>}
{result.recommendations && Array.isArray(result.recommendations) && (
<div>
<span className="text-muted-foreground"></span>
<ul className="ml-4 list-disc text-xs">
{result.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
)}
</div>
);
}
/** BML 认知追踪组件。 */
export function BMLTracker() {
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<GitBranch size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium">BML </h3>
</div>
<div className="mt-2 space-y-2 text-sm text-muted-foreground">
<p> </p>
<p className="text-xs"></p>
</div>
</div>
);
}
/** 鸿沟诊断预警组件。 */
export function ChasmAlert() {
const [companyData, setCompanyData] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
async function handleDiagnose() {
if (!companyData.trim()) return;
setLoading(true);
try {
const res = await apiFetch<any>("/advanced-analysis/chasm", {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
setResult(res.data);
} catch {
// ignore
} finally {
setLoading(false);
}
}
return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<TrendingDown size={18} className="text-rose-500" />
<h3 className="text-sm font-medium">鸿</h3>
</div>
<textarea
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
rows={3}
placeholder="描述产品采用情况、客户类型分布..."
value={companyData}
onChange={(e) => setCompanyData(e.target.value)}
/>
<button
onClick={handleDiagnose}
disabled={loading || !companyData.trim()}
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
{loading ? "诊断中..." : "鸿沟诊断"}
</button>
{loading && <div className="mt-2"><LoadingSpinner /></div>}
{result && (
<div className="mt-3 space-y-2 text-sm">
{result.stage && <div><span className="text-muted-foreground"></span>{result.stage}</div>}
{result.gap_detected !== undefined && (
<div className={`rounded-md px-3 py-1 text-xs ${result.gap_detected ? "bg-rose-100 text-rose-700" : "bg-emerald-100 text-emerald-700"}`}>
{result.gap_detected ? "⚠ 检测到鸿沟风险" : "✓ 未检测到鸿沟"}
</div>
)}
{result.crossing_strategy && (
<div><span className="text-muted-foreground"></span>{result.crossing_strategy}</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useState, type ReactNode } from "react";
import {
LayoutDashboard, DollarSign, Activity, Users, Bot,
AlertTriangle, FileText, ClipboardList, ScrollText, Network,
} from "lucide-react";
/** 工作台 Tab 定义。 */
const TABS = [
{ id: "overview", label: "概览", icon: LayoutDashboard },
{ id: "financial", label: "财务", icon: DollarSign },
{ id: "operational", label: "经营", icon: Activity },
{ id: "org", label: "组织", icon: Users },
{ id: "ai", label: "AI+专项", icon: Bot },
{ id: "risk", label: "风险", icon: AlertTriangle },
{ id: "reports", label: "月报", icon: FileText },
{ id: "board", label: "董事会", icon: ClipboardList },
{ id: "agreements", label: "协议", icon: ScrollText },
{ id: "synergy", label: "协同", icon: Network },
] as const;
export type TabId = (typeof TABS)[number]["id"];
/** 工作台左侧 Tab 导航组件。 */
export function TabNav({
active,
onChange,
}: {
active: TabId;
onChange: (tab: TabId) => void;
}) {
return (
<nav className="flex flex-row gap-1 overflow-x-auto border-b border-[var(--border)] md:w-40 md:flex-col md:overflow-y-auto md:border-b-0 md:border-r">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`flex shrink-0 items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
active === tab.id
? "bg-[var(--investor-primary)]/10 font-medium text-[var(--investor-primary)]"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
<tab.icon size={16} aria-hidden="true" />
{tab.label}
</button>
))}
</nav>
);
}
/** 工作台 Tab 面板容器。 */
export function TabPanel({ children }: { children: ReactNode }) {
return <div className="flex-1 space-y-4 p-4">{children}</div>;
}
+533
View File
@@ -0,0 +1,533 @@
/** Phase 2-4 通用 API 调用封装。 */
import { apiFetch, type ApiResponse } from "@/lib/api";
// ============ Phase 2: 财务数据 ============
export async function listFinancialData(companyId: string) {
return apiFetch(`/financial?company_id=${companyId}`);
}
export async function createFinancialData(data: Record<string, unknown>) {
return apiFetch(`/financial`, {
method: "POST",
body: JSON.stringify(data),
});
}
export async function validateFinancial(companyId: string, year: number, month: number) {
return apiFetch(`/financial/validate?company_id=${companyId}&period_year=${year}&period_month=${month}`);
}
// ============ Phase 2: 投资协议 ============
export async function listAgreements(companyId?: string) {
const path = companyId ? `/agreements?company_id=${companyId}` : "/agreements";
return apiFetch(path);
}
export async function createAgreement(data: Record<string, unknown>) {
return apiFetch(`/agreements`, { method: "POST", body: JSON.stringify(data) });
}
export async function getAgreementAlerts(agreementId: string) {
return apiFetch(`/agreements/${agreementId}/alerts`);
}
// ============ Phase 2: 董事会 ============
export async function listBoardMeetings(companyId?: string) {
const path = companyId ? `/board?company_id=${companyId}` : "/board";
return apiFetch(path);
}
export async function createBoardMeeting(data: Record<string, unknown>) {
return apiFetch(`/board`, { method: "POST", body: JSON.stringify(data) });
}
export async function generateMeetingSummary(meetingId: string, materialsText: string) {
return apiFetch(`/board/${meetingId}/generate-summary`, {
method: "POST",
body: JSON.stringify({ materials_text: materialsText }),
});
}
export async function generateBoardQuestions(meetingId: string, materialsText: string) {
return apiFetch(`/board/${meetingId}/generate-questions`, {
method: "POST",
body: JSON.stringify({ materials_text: materialsText }),
});
}
// ============ Phase 2: 弱信号 ============
export async function listWeakSignals(companyId?: string, signalType?: string) {
const params = new URLSearchParams();
if (companyId) params.set("company_id", companyId);
if (signalType) params.set("signal_type", signalType);
return apiFetch(`/weak-signals${params.toString() ? `?${params}` : ""}`);
}
export async function correlateWeakSignals(signals: unknown[]) {
return apiFetch(`/weak-signals/correlate`, {
method: "POST",
body: JSON.stringify({ signals }),
});
}
// ============ Phase 2: 决策前哨 ============
export async function listDecisionSentinels(companyId?: string) {
const path = companyId ? `/decision-sentinels?company_id=${companyId}` : "/decision-sentinels";
return apiFetch(path);
}
export async function identifyDecisionPoints(companyContext: string) {
return apiFetch(`/decision-sentinels/identify`, {
method: "POST",
body: JSON.stringify({ company_context: companyContext }),
});
}
export async function analyzeSentinelScenarios(sentinelId: string, decision: Record<string, unknown>) {
return apiFetch(`/decision-sentinels/${sentinelId}/analyze`, {
method: "POST",
body: JSON.stringify({ decision }),
});
}
// ============ Phase 2: 重大事项 + 追问清单 ============
export async function listMajorEvents(companyId?: string) {
const path = companyId ? `/events?company_id=${companyId}` : "/events";
return apiFetch(path);
}
export async function detectMajorEvents(reportContent: string) {
return apiFetch(`/events/detect`, {
method: "POST",
body: JSON.stringify({ report_content: reportContent }),
});
}
export async function listInquiries(companyId?: string) {
const path = companyId ? `/inquiries?company_id=${companyId}` : "/inquiries";
return apiFetch(path);
}
export async function generateInquiryQuestions(reportContent: string, structuredData?: unknown) {
return apiFetch(`/inquiries/generate`, {
method: "POST",
body: JSON.stringify({ report_content: reportContent, structured_data: structuredData }),
});
}
// ============ Phase 2: 画像 ============
export async function listFirms() {
return apiFetch(`/profiles/firms`);
}
export async function listFunds(firmId?: string) {
const path = firmId ? `/profiles/funds?firm_id=${firmId}` : "/profiles/funds";
return apiFetch(path);
}
export async function listManagers(firmId?: string) {
const path = firmId ? `/profiles/managers?firm_id=${firmId}` : "/profiles/managers";
return apiFetch(path);
}
// ============ Phase 3: 协同 ============
export async function listSynergies(companyId?: string) {
const path = companyId ? `/synergies?company_id=${companyId}` : "/synergies";
return apiFetch(path);
}
export async function matchSynergies(companyAContext: string, portfolioContext: string) {
return apiFetch(`/synergies/match`, {
method: "POST",
body: JSON.stringify({ company_a_context: companyAContext, portfolio_context: portfolioContext }),
});
}
export async function authorizeSynergy(synergyId: string) {
return apiFetch(`/synergies/${synergyId}/authorize`, { method: "PUT" });
}
// ============ Phase 3: 创新 ============
export async function discoverInnovation(portfolioCapabilities: string) {
return apiFetch(`/innovation/discover`, {
method: "POST",
body: JSON.stringify({ portfolio_capabilities: portfolioCapabilities }),
});
}
// ============ Phase 3: 人才 ============
export async function listTalents() {
return apiFetch(`/talents`);
}
export async function predictTalentFlow(talentData: string) {
return apiFetch(`/talents/predict-flow`, {
method: "POST",
body: JSON.stringify({ talent_data: talentData }),
});
}
export async function recommendTalent(companyNeed: string, talentPool: string) {
return apiFetch(`/talents/recommend`, {
method: "POST",
body: JSON.stringify({ company_need: companyNeed, talent_pool: talentPool }),
});
}
// ============ Phase 3: OKR ============
export async function listOKRs(companyId?: string) {
const path = companyId ? `/okrs?company_id=${companyId}` : "/okrs";
return apiFetch(path);
}
export async function createOKR(data: Record<string, unknown>) {
return apiFetch(`/okrs`, { method: "POST", body: JSON.stringify(data) });
}
export async function trackOKR(okrId: string, keyResults: unknown[]) {
return apiFetch(`/okrs/${okrId}/track`, {
method: "POST",
body: JSON.stringify({ key_results: keyResults }),
});
}
// ============ Phase 3: 助推 ============
export async function listNudges(companyId?: string) {
const path = companyId ? `/nudges?company_id=${companyId}` : "/nudges";
return apiFetch(path);
}
export async function selectNudge(context: string) {
return apiFetch(`/nudges/select`, {
method: "POST",
body: JSON.stringify({ context }),
});
}
// ============ Phase 3: Peer Circles ============
export async function listPeerCircles() {
return apiFetch(`/peer-circles`);
}
export async function matchPeerCircle(foundersContext: string) {
return apiFetch(`/peer-circles/match`, {
method: "POST",
body: JSON.stringify({ founders_context: foundersContext }),
});
}
// ============ Phase 3: 产品诊断 ============
export async function listProductDiagnostics(companyId?: string) {
const path = companyId ? `/product-diagnostics?company_id=${companyId}` : "/product-diagnostics";
return apiFetch(path);
}
export async function diagnoseProduct(productInfo: string, competitorInfo: string) {
return apiFetch(`/product-diagnostics/diagnose`, {
method: "POST",
body: JSON.stringify({ product_info: productInfo, competitor_info: competitorInfo }),
});
}
// ============ Phase 3: 里程碑 ============
export async function listMilestones(companyId: string) {
return apiFetch(`/milestones?company_id=${companyId}`);
}
export async function suggestMilestoneSwitch(milestoneContext: string, envChanges: string) {
return apiFetch(`/milestones/suggest-switch`, {
method: "POST",
body: JSON.stringify({ milestone_context: milestoneContext, env_changes: envChanges }),
});
}
// ============ Phase 3: 高级分析 ============
export async function analyzeConstraints(companyData: string) {
return apiFetch(`/advanced-analysis/constraints`, {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
}
export async function analyzeChasm(companyData: string) {
return apiFetch(`/advanced-analysis/chasm`, {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
}
// ============ Phase 3: 任务 + 评论 ============
export async function listTasks(companyId?: string, status?: string) {
const params = new URLSearchParams();
if (companyId) params.set("company_id", companyId);
if (status) params.set("status", status);
return apiFetch(`/tasks${params.toString() ? `?${params}` : ""}`);
}
export async function createTask(data: Record<string, unknown>) {
return apiFetch(`/tasks`, { method: "POST", body: JSON.stringify(data) });
}
export async function updateTask(taskId: string, data: Record<string, unknown>) {
return apiFetch(`/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) });
}
export async function listComments(targetType: string, targetId: string) {
return apiFetch(`/comments?target_type=${targetType}&target_id=${targetId}`);
}
export async function createComment(data: Record<string, unknown>) {
return apiFetch(`/comments`, { method: "POST", body: JSON.stringify(data) });
}
// ============ Phase 3: 客户成功 ============
export async function generateQBR(companyId: string, quarterData: string) {
return apiFetch(`/customer-success/qbr`, {
method: "POST",
body: JSON.stringify({ company_id: companyId, quarter_data: quarterData }),
});
}
export async function identifyExpansion(companyData: string) {
return apiFetch(`/customer-success/expansion`, {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
}
export async function getChurnRisk() {
return apiFetch(`/customer-success/churn-risk`);
}
// ============ Phase 4: Alpha 归因 ============
export async function listInterventions(companyId?: string) {
const path = companyId ? `/alpha?company_id=${companyId}` : "/alpha";
return apiFetch(path);
}
export async function createIntervention(data: Record<string, unknown>) {
return apiFetch(`/alpha`, { method: "POST", body: JSON.stringify(data) });
}
export async function attributeAlpha(interventionId: string, intervention: unknown, metricChanges: unknown) {
return apiFetch(`/alpha/${interventionId}/attribute`, {
method: "POST",
body: JSON.stringify({ intervention, metric_changes: metricChanges }),
});
}
// ============ Phase 4: 退出预测 ============
export async function listExitPredictions(companyId?: string) {
const path = companyId ? `/exit-predictions?company_id=${companyId}` : "/exit-predictions";
return apiFetch(path);
}
export async function predictExit(companyData: string) {
return apiFetch(`/exit-predictions/predict`, {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
}
// ============ Phase 4: 组合管理 ============
export async function rebalancePortfolio(companyReturns: unknown[]) {
return apiFetch(`/portfolio/rebalance`, {
method: "POST",
body: JSON.stringify({ company_returns: companyReturns }),
});
}
export async function runMonteCarlo(companyReturns: number[], iterations?: number) {
return apiFetch(`/portfolio/monte-carlo`, {
method: "POST",
body: JSON.stringify({ company_returns: companyReturns, iterations }),
});
}
// ============ Phase 4: 数字孪生 ============
export async function listDigitalTwins(companyId: string) {
return apiFetch(`/digital-twins?company_id=${companyId}`);
}
export async function buildDigitalTwin(companyData: string) {
return apiFetch(`/digital-twins/build`, {
method: "POST",
body: JSON.stringify({ company_data: companyData }),
});
}
export async function simulateTwin(modelParams: unknown, scenario: string) {
return apiFetch(`/digital-twins/simulate`, {
method: "POST",
body: JSON.stringify({ model_params: modelParams, scenario }),
});
}
// ============ Phase 4: 知识图谱 ============
export async function buildKnowledgeGraph(managementExperiences: string) {
return apiFetch(`/knowledge-graph/build`, {
method: "POST",
body: JSON.stringify({ management_experiences: managementExperiences }),
});
}
export async function matchBestStrategy(companyProfile: string, graph: unknown) {
return apiFetch(`/knowledge-graph/match-strategy`, {
method: "POST",
body: JSON.stringify({ new_company_profile: companyProfile, knowledge_graph: graph }),
});
}
// ============ Phase 4: AAR ============
export async function listAARs(companyId?: string) {
const path = companyId ? `/aars?company_id=${companyId}` : "/aars";
return apiFetch(path);
}
export async function generateAAR(triggerEvent: string, originalPlan: string, actualResult: string) {
return apiFetch(`/aars/generate`, {
method: "POST",
body: JSON.stringify({ trigger_event: triggerEvent, original_plan: originalPlan, actual_result: actualResult }),
});
}
// ============ Phase 4: Pre-mortem + Red Team ============
export async function listPreMortems(companyId?: string) {
const path = companyId ? `/pre-mortems?company_id=${companyId}` : "/pre-mortems";
return apiFetch(path);
}
export async function runPreMortem(decisionContext: string) {
return apiFetch(`/pre-mortems/run`, {
method: "POST",
body: JSON.stringify({ decision_context: decisionContext }),
});
}
export async function listRedTeams(companyId?: string) {
const path = companyId ? `/red-teams?company_id=${companyId}` : "/red-teams";
return apiFetch(path);
}
export async function runRedTeam(companyContext: string, perspective: string) {
return apiFetch(`/red-teams/run`, {
method: "POST",
body: JSON.stringify({ company_context: companyContext, perspective }),
});
}
// ============ Phase 4: Agent 执行 ============
export async function listAgentExecutions(page?: number) {
return apiFetch(`/agent-executions?page=${page ?? 1}`);
}
export async function orchestrateAgent(agentName: string, autonomyLevel: string, inputData: unknown) {
return apiFetch(`/agent-executions/orchestrate`, {
method: "POST",
body: JSON.stringify({ agent_name: agentName, autonomy_level: autonomyLevel, input_data: inputData }),
});
}
export async function reviewAgentExecution(executionId: string, reviewStatus: string) {
return apiFetch(`/agent-executions/${executionId}/review`, {
method: "PUT",
body: JSON.stringify({ review_status: reviewStatus }),
});
}
// ============ Phase 4: 知识库 ============
export async function searchKnowledge(query: string, topK?: number) {
return apiFetch(`/knowledge/search?q=${encodeURIComponent(query)}&top_k=${topK ?? 5}`);
}
// ============ Phase 4: 数据源 ============
export async function listDataSources(companyId?: string) {
const path = companyId ? `/data-sources?company_id=${companyId}` : "/data-sources";
return apiFetch(path);
}
export async function createDataSource(data: Record<string, unknown>) {
return apiFetch(`/data-sources`, { method: "POST", body: JSON.stringify(data) });
}
// ============ Phase 4: 行业研究 ============
export async function researchIndustry(industry: string, companies: string) {
return apiFetch(`/industry-research/research`, {
method: "POST",
body: JSON.stringify({ industry, companies }),
});
}
// ============ Phase 4: 基金 ============
export async function analyzeFundStrategy(fundsData: string) {
return apiFetch(`/funds/analyze-strategy`, {
method: "POST",
body: JSON.stringify({ funds_data: fundsData }),
});
}
export async function generateLPReport(fundData: string, portfolioSummary: string) {
return apiFetch(`/funds/lp-report`, {
method: "POST",
body: JSON.stringify({ fund_data: fundData, portfolio_summary: portfolioSummary }),
});
}
// ============ Admin ============
export async function adminOverview() {
return apiFetch(`/admin/overview`);
}
export async function listTenants() {
return apiFetch(`/admin/tenants`);
}
export async function listUsers() {
return apiFetch(`/admin/users`);
}
export async function listAuditLogs(page?: number) {
return apiFetch(`/admin/audit-logs?page=${page ?? 1}`);
}
// ============ Founder ============
export async function founderOverview() {
return apiFetch(`/founder/overview`);
}
export async function founderHealth() {
return apiFetch(`/founder/health`);
}
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}