feat(frontend): T1.2 企业列表 + 详情页 — 投资人端
- 企业列表页:搜索、分页、卡片展示 - 企业详情页:基本信息 + 业务描述 - API 客户端:companies.ts 封装 CRUD 函数 - 前端构建 11 路由成功
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
Reference in New Issue
Block a user