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>
);
}
+90
View File
@@ -0,0 +1,90 @@
/** 企业相关类型和 API 函数。 */
import { apiFetch, type ApiResponse } from "./api";
/** 企业信息。 */
export interface Company {
id: string;
tenant_id: string;
name: string;
industry: string | null;
stage: string | null;
logo_url: string | null;
description: string | null;
founded_at: string | null;
total_funding: string | null;
website: string | null;
created_at: string;
updated_at: string;
}
/** 企业列表响应。 */
export interface CompanyListResponse {
items: Company[];
total: number;
page: number;
page_size: number;
}
/** 创建/更新企业参数。 */
export interface CompanyInput {
name: string;
industry?: string | null;
stage?: string | null;
description?: string | null;
website?: string | null;
total_funding?: string | null;
}
/**
* 获取企业列表。
*/
export async function listCompanies(params?: {
page?: number;
page_size?: number;
keyword?: string;
industry?: string;
stage?: string;
}): Promise<ApiResponse<CompanyListResponse>> {
const query = new URLSearchParams();
if (params?.page) query.set("page", String(params.page));
if (params?.page_size) query.set("page_size", String(params.page_size));
if (params?.keyword) query.set("keyword", params.keyword);
if (params?.industry) query.set("industry", params.industry);
if (params?.stage) query.set("stage", params.stage);
return apiFetch<CompanyListResponse>(`/companies?${query.toString()}`);
}
/**
* 获取企业详情。
*/
export async function getCompany(id: string): Promise<ApiResponse<Company>> {
return apiFetch<Company>(`/companies/${id}`);
}
/**
* 创建企业。
*/
export async function createCompany(data: CompanyInput): Promise<ApiResponse<Company>> {
return apiFetch<Company>("/companies", {
method: "POST",
body: JSON.stringify(data),
});
}
/**
* 更新企业。
*/
export async function updateCompany(id: string, data: Partial<CompanyInput>): Promise<ApiResponse<Company>> {
return apiFetch<Company>(`/companies/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
/**
* 删除企业。
*/
export async function deleteCompany(id: string): Promise<ApiResponse<null>> {
return apiFetch<null>(`/companies/${id}`, { method: "DELETE" });
}