feat(frontend): T1.2 企业列表 + 详情页 — 投资人端

- 企业列表页:搜索、分页、卡片展示
- 企业详情页:基本信息 + 业务描述
- API 客户端:companies.ts 封装 CRUD 函数
- 前端构建 11 路由成功
This commit is contained in:
selfrelease
2026-07-18 21:58:47 +08:00
parent 8fe8047429
commit 0bcaafd0eb
3 changed files with 390 additions and 0 deletions
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useState, use } from "react";
import { Building2, Globe, Calendar, DollarSign, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { getCompany, type Company } from "@/lib/companies";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
/**
* 投资人端 — 企业详情页。
*/
export default function CompanyDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [company, setCompany] = useState<Company | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function load() {
setIsLoading(true);
try {
const resp = await getCompany(id);
if (resp.data) {
setCompany(resp.data);
}
} catch (err) {
setError(err instanceof Error ? err.message : "加载失败");
} finally {
setIsLoading(false);
}
}
load();
}, [id]);
if (isLoading) {
return (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
);
}
if (error || !company) {
return (
<div className="space-y-4">
<Link href="/companies" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} aria-hidden="true" />
</Link>
<EmptyState title="企业不存在" description={error || "未找到该企业"} />
</div>
);
}
return (
<div className="space-y-6">
<Link href="/companies" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} aria-hidden="true" />
</Link>
{/* 企业头部 */}
<div className="flex items-start justify-between rounded-lg border bg-white p-6 shadow-sm">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-[var(--investor-primary)]/10">
<Building2 className="text-[var(--investor-primary)]" size={32} aria-hidden="true" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">{company.name}</h1>
<div className="mt-1 flex gap-2">
{company.industry && (
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
{company.industry}
</span>
)}
{company.stage && (
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
{company.stage.toUpperCase()}
</span>
)}
</div>
</div>
</div>
<HealthScoreBadge score={75} />
</div>
{/* 详细信息 */}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold text-muted-foreground"></h2>
<dl className="space-y-3">
{company.website && (
<div className="flex items-center gap-2 text-sm">
<Globe size={16} className="text-muted-foreground" aria-hidden="true" />
<a
href={company.website}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--investor-primary)] hover:underline"
>
{company.website}
</a>
</div>
)}
{company.founded_at && (
<div className="flex items-center gap-2 text-sm">
<Calendar size={16} className="text-muted-foreground" aria-hidden="true" />
<span> {new Date(company.founded_at).toLocaleDateString("zh-CN")}</span>
</div>
)}
{company.total_funding && (
<div className="flex items-center gap-2 text-sm">
<DollarSign size={16} className="text-muted-foreground" aria-hidden="true" />
<span>{company.total_funding}</span>
</div>
)}
</dl>
</div>
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold text-muted-foreground"></h2>
<p className="text-sm text-foreground">
{company.description || "暂无描述"}
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Building2, Search, Plus, ArrowRight } from "lucide-react";
import Link from "next/link";
import { listCompanies, type Company } from "@/lib/companies";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
/**
* 投资人端 — 企业列表页。
*/
export default function CompaniesPage() {
const [companies, setCompanies] = useState<Company[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const pageSize = 20;
const loadCompanies = useCallback(async () => {
setIsLoading(true);
try {
const resp = await listCompanies({ page, page_size: pageSize, keyword: keyword || undefined });
if (resp.data) {
setCompanies(resp.data.items);
setTotal(resp.data.total);
}
} catch {
setCompanies([]);
} finally {
setIsLoading(false);
}
}, [page, keyword]);
useEffect(() => {
loadCompanies();
}, [loadCompanies]);
function handleSearch(e: React.FormEvent) {
e.preventDefault();
setPage(1);
loadCompanies();
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground"> {total} </p>
</div>
<button className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90">
<Plus size={16} aria-hidden="true" />
</button>
</div>
{/* 搜索栏 */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
size={16}
aria-hidden="true"
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索企业名称..."
className="w-full rounded-md border bg-background py-2 pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--investor-primary)]"
/>
</div>
<button
type="submit"
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-muted"
>
</button>
</form>
{/* 企业列表 */}
{isLoading ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : companies.length === 0 ? (
<EmptyState
title="暂无企业"
description="点击右上角添加企业开始管理"
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{companies.map((company) => (
<Link
key={company.id}
href={`/companies/${company.id}`}
className="group rounded-lg border bg-white p-5 shadow-sm transition-all hover:shadow-md"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--investor-primary)]/10">
<Building2 className="text-[var(--investor-primary)]" size={20} aria-hidden="true" />
</div>
<div>
<h3 className="font-semibold text-foreground group-hover:text-[var(--investor-primary)]">
{company.name}
</h3>
<p className="text-xs text-muted-foreground">{company.industry || "未分类"}</p>
</div>
</div>
<HealthScoreBadge score={75} />
</div>
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{company.description || "暂无描述"}
</p>
<div className="mt-4 flex items-center justify-between border-t pt-3">
<div className="flex gap-2">
{company.stage && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{company.stage.toUpperCase()}
</span>
)}
{company.total_funding && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{company.total_funding}
</span>
)}
</div>
<ArrowRight
size={16}
className="text-muted-foreground transition-transform group-hover:translate-x-1"
aria-hidden="true"
/>
</div>
</Link>
))}
</div>
)}
{/* 分页 */}
{total > pageSize && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded-md border px-3 py-1.5 text-sm disabled:opacity-50"
>
</button>
<span className="text-sm text-muted-foreground">
{page} / {Math.ceil(total / pageSize)}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= Math.ceil(total / pageSize)}
className="rounded-md border px-3 py-1.5 text-sm disabled:opacity-50"
>
</button>
</div>
)}
</div>
);
}