186 lines
21 KiB
TypeScript
186 lines
21 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useRouter } from "next/router";
|
||
import IncomeDistribution from "./IncomeDistribution";
|
||
import ArrDisclosures from "./ArrDisclosures";
|
||
import BusinessModelAssessment from "./BusinessModelAssessment";
|
||
import {
|
||
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
|
||
} from "@/lib/ai-research";
|
||
|
||
const pageSize = 12;
|
||
const evidencePageSize = 30;
|
||
const revenueLabels = { self: "企业披露,待核口径", estimate: "仅第三方估算", platform: "仅支付平台记录", unknown: "暂无可用收入数字" };
|
||
const useLabels: Record<string, string> = {
|
||
platform: "支付平台记录", signal: "企业披露信号", estimate: "第三方估算", historical: "历史里程碑",
|
||
"historical-estimate": "其他时点估算", excluded: "排除收入比较", scale: "团队规模", adoption: "采用规模",
|
||
funding: "融资", technical: "第三方技术评测", crosscheck: "同一里程碑的其他资料",
|
||
};
|
||
const money = (value: number, unit = "USD") => unit === "USD"
|
||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value)
|
||
: `${new Intl.NumberFormat("zh-CN").format(value)} ${unit}`;
|
||
|
||
function External({ url, children }: { url: string; children: React.ReactNode }) {
|
||
const safe = evidenceUrl(url);
|
||
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{children} ↗</a> : <span>{children}</span>;
|
||
}
|
||
function Evidence({ item, prefix = "evidence" }: { item: ResearchEvidence; prefix?: string }) {
|
||
return <li id={`${prefix}-${item.id}`} className="research-evidence">
|
||
<div><code>{item.id}</code><span className="research-tag">{item.kind}</span></div>
|
||
<strong><External url={item.url}>{item.title}</External></strong>
|
||
<p>{item.summary}</p><p className="research-muted">{item.limitation}</p>
|
||
<small>发布:{item.publishedAt ?? "未注明"} 读取:{item.accessedAt}</small>
|
||
</li>;
|
||
}
|
||
|
||
export default function AICompanyResearch() {
|
||
const router = useRouter();
|
||
const [data, setData] = useState<ResearchSnapshot | null>(null);
|
||
const [error, setError] = useState(false);
|
||
const [attempt, setAttempt] = useState(0);
|
||
const [query, setQuery] = useState("");
|
||
const [categoryChoice, setCategoryChoice] = useState<{ route: string; id: string } | null>(null);
|
||
const linkedCategory = router.query.category;
|
||
const category = categoryChoice && categoryChoice.route === router.asPath ? categoryChoice.id
|
||
: router.isReady && typeof linkedCategory === "string" && data?.categories.some((c) => c.id === linkedCategory)
|
||
? linkedCategory : "";
|
||
function setCategory(id: string) { setCategoryChoice({ route: router.asPath, id }); }
|
||
const [cohort, setCohort] = useState("");
|
||
const [income, setIncome] = useState("");
|
||
const [page, setPage] = useState(0);
|
||
const [sourcePage, setSourcePage] = useState(0);
|
||
const [depth, setDepth] = useState("");
|
||
const [teamBand, setTeamBand] = useState("");
|
||
const [technicalLevel, setTechnicalLevel] = useState("");
|
||
const [selection, setSelection] = useState<{ route: string; id: string | null } | null>(null);
|
||
const linkedCompany = router.query.company;
|
||
const selected = selection && selection.route === router.asPath ? selection.id
|
||
: router.isReady && typeof linkedCompany === "string" && data?.companies.some((c) => c.id === linkedCompany)
|
||
? linkedCompany : null;
|
||
function setSelected(id: string | null) { setSelection({ route: router.asPath, id }); }
|
||
const [tab, setTab] = useState<"companies" | "revenue" | "models" | "sources" | "reviews">("companies");
|
||
const companySection = useRef<HTMLElement>(null);
|
||
const detailSection = useRef<HTMLElement>(null);
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
fetch("/api/research/ai-companies", { signal: controller.signal, cache: "no-store" })
|
||
.then(async (response) => {
|
||
if (!response.ok) throw new Error("Research unavailable");
|
||
const snapshot: ResearchSnapshot = await response.json();
|
||
setData(snapshot);
|
||
}).catch(() => { if (!controller.signal.aborted) setError(true); });
|
||
return () => controller.abort();
|
||
}, [attempt]);
|
||
useEffect(() => { if (selected) detailSection.current?.focus(); }, [selected]);
|
||
const summaries = useMemo(() => data ? categorySummary(data) : [], [data]);
|
||
const filtered = useMemo(() => data?.companies.filter((c) => {
|
||
const search = `${c.name} ${c.business} ${c.location ?? ""} ${c.buyer}`.toLocaleLowerCase();
|
||
const yearMatch = !cohort || (cohort === "other" ? ![2025, 2026].includes(c.year ?? 0) : c.year === Number(cohort));
|
||
const depthMatch = !depth || (depth === "profile" ? c.profileOnly : !c.profileOnly);
|
||
const teamMatch = !teamBand || (teamBand === "unknown" ? c.team === null || (!!c.teamBound && c.team < 51)
|
||
: c.team !== null && (teamBand === "xl" ? c.team >= 51 : !c.teamBound && (teamBand === "small" ? c.team <= 5 : teamBand === "medium" ? c.team >= 6 && c.team <= 20 : c.team >= 21 && c.team <= 50)));
|
||
return (!technicalLevel || (c.technicalReview?.level ?? "unreviewed") === technicalLevel) && depthMatch && teamMatch && (!category || c.cat === category) && yearMatch && (!income || c.revenueStatus === income)
|
||
&& search.includes(query.trim().toLocaleLowerCase());
|
||
}) ?? [], [data, query, category, cohort, income, depth, teamBand, technicalLevel]);
|
||
if (error) return <div className="research-message" role="alert">研究数据库暂时不可用。<button onClick={() => { setError(false); setAttempt((n) => n + 1); }}>重新读取</button></div>;
|
||
if (!data) return <p className="research-message" role="status">正在读取研究数据库…</p>;
|
||
const categories = new Map(data.categories.map((c) => [c.id, c]));
|
||
const selectedCompany = data.companies.find((c) => c.id === selected);
|
||
const observed = data.observations.filter((o) => ["signal", "estimate"].includes(o.selection));
|
||
const disclosedCompanies = new Set(observed.map((o) => o.company)).size;
|
||
const revenueCompanies = data.companies.filter((c) => c.revenueStatus !== "unknown").length;
|
||
const selectedObservations = data.observations.filter((o) => o.company === selected);
|
||
const selectedEvidenceIds = new Set([
|
||
...(selectedCompany?.evidenceIds ?? []), ...(selectedCompany?.pricingEvidence ? [selectedCompany.pricingEvidence] : []),
|
||
...(selectedCompany?.technicalReview?.evidenceIds ?? []),
|
||
...selectedObservations.map((o) => o.evidence),
|
||
]);
|
||
const selectedEvidence = data.evidence.filter((e) => e.companyId === selected || selectedEvidenceIds.has(e.id));
|
||
const reviewCompanies = (review: ResearchReview) => {
|
||
const ids = new Set(reviewCompanyIds(data, review));
|
||
return data.companies.filter((c) => ids.has(c.id));
|
||
};
|
||
const selectedReviews = data.reviews.filter((r) => selected && reviewCompanyIds(data, r).includes(selected));
|
||
function chooseCategory(id: string) {
|
||
setCategory(id); setPage(0); setTab("companies");
|
||
companySection.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
return <div className="ai-research not-prose">
|
||
<div className="research-meta"><span>资料截至 {data.asOf}</span><span>首批研究 · 持续补全</span></div>
|
||
<div className="research-stats">
|
||
<div><strong>{data.companies.length}</strong><span>公司/产品记录</span></div>
|
||
<div><strong>{data.categories.length}</strong><span>业务类别</span></div>
|
||
<div><strong>{revenueCompanies}</strong><span>有收入线索的记录</span></div>
|
||
<div><strong>{data.evidence.length}</strong><span>证据记录</span></div>
|
||
</div>
|
||
<div className="research-scope"><p>{data.scope}</p>
|
||
{data.coverage && <p>YC 候选池 {data.coverage.candidateRecords} 条,官方页面读取 {data.coverage.profilesRead} 条;初筛匹配 {data.coverage.matchingProfiles} 条,新增 {data.coverage.newProfiles} 条目录记录。年份不符或缺失 {data.coverage.otherOrMissingYear} 条、AI 业务待确认 {data.coverage.unclearAI} 条、日期冲突 {data.coverage.dateConflicts} 条。目录基础资料尚未逐家核验收入和交付能力。</p>}
|
||
<details><summary>数据口径与覆盖限制</summary><ul>{data.limitations.map((note) => <li key={note}>{note}</li>)}</ul>
|
||
<p>来源以 YC、公司官网、投资机构名录与经营案例为主。同一披露的转载不构成独立验证。团队人数包含公司自报的 Team Size,与正式员工口径可能不同。</p>
|
||
</details>
|
||
</div>
|
||
<section aria-labelledby="research-categories-title" className="research-section">
|
||
<h2 id="research-categories-title">业务分类</h2><p className="research-muted">点击类别查看对应公司。数量反映本次采集覆盖,不是市场份额。</p>
|
||
<div className="research-categories">{summaries.map((c) => <button key={c.id} onClick={() => chooseCategory(c.id)} aria-label={`查看${c.name}的${c.count}条记录`}>
|
||
<div><span>{c.name}</span><strong>{c.count}</strong></div>
|
||
<span className="research-bar" aria-hidden="true"><i style={{ width: `${c.count / Math.max(1, ...summaries.map((s) => s.count)) * 100}%` }} /></span>
|
||
<small>{c.description}</small>
|
||
</button>)}</div>
|
||
</section>
|
||
<section ref={companySection} className="research-section" aria-labelledby="research-records-title">
|
||
<h2 id="research-records-title">研究数据</h2>
|
||
<div className="research-tabs" role="group" aria-label="研究数据视图">
|
||
{([['companies','公司与产品'],['revenue','收入观测'],['models','商业模式初评'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
|
||
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
|
||
</div>
|
||
{tab === "companies" && <>
|
||
<div className="research-filters">
|
||
<label className="research-query">搜索公司<input type="search" value={query} placeholder="名称、业务、地区或买家" onChange={(e) => { setQuery(e.target.value); setPage(0); }} /></label>
|
||
<label>业务类别<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(0); }}><option value="">全部类别</option>{data.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></label>
|
||
<label>成立或发布<select value={cohort} onChange={(e) => { setCohort(e.target.value); setPage(0); }}><option value="">全部组别</option><option value="2025">2025 年成立</option><option value="2026">2026 年成立</option><option value="other">公开发布与转型对照</option></select></label>
|
||
<label>资料深度<select value={depth} onChange={(e) => { setDepth(e.target.value); setPage(0); }}><option value="">全部资料</option><option value="case">原有个案研究</option><option value="profile">目录基础资料</option></select></label>
|
||
<label>记录团队规模<select value={teamBand} onChange={(e) => { setTeamBand(e.target.value); setPage(0); }}><option value="">全部规模</option><option value="small">1—5 人</option><option value="medium">6—20 人</option><option value="large">21—50 人</option><option value="xl">至少 51 人</option><option value="unknown">未披露或分组不确定</option></select></label>
|
||
<label>技术验证证据<select value={technicalLevel} onChange={(e) => { setTechnicalLevel(e.target.value); setPage(0); }}><option value="">全部验证层级</option>{Object.entries(technicalReviewLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||
<label>收入资料<select value={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到企业披露</option><option value="estimate">仅第三方估算</option><option value="platform">仅支付平台记录</option><option value="unknown">暂无可用数字</option></select></label>
|
||
</div>
|
||
<p className="research-muted">技术验证层级记录我们查到了什么,不是能力强弱评分。技术专项评审与业务分类复核分别记录。</p>
|
||
<div className="research-result-count" role="status">{filtered.length} 条匹配记录<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); setDepth(""); setTeamBand(""); setTechnicalLevel(""); setPage(0); }}>清除筛选</button></div>
|
||
<div className="research-table-wrap"><table><caption className="sr-only">公司/产品分类与经营资料。选择名称查看指标和原始证据。</caption><thead><tr><th>公司或产品 / 地区</th><th>业务与买家</th><th>成立 / 发布组别</th><th>团队规模</th><th>交付与技术验证证据</th><th>收入资料</th></tr></thead><tbody>
|
||
{filtered.slice(page * pageSize, (page + 1) * pageSize).map((c) => <tr key={c.id} data-selected={selected === c.id || undefined}>
|
||
<td><button className="research-company-name" aria-expanded={selected === c.id} aria-controls="research-company-detail" onClick={() => setSelected(c.id)}>{c.name}</button><small>{c.location ?? "地区未注明"}</small></td>
|
||
<td><span className="research-tag">{categories.get(c.cat)?.name}</span>{c.profileOnly && <small>目录基础资料 · 分类依据见明细</small>}<p>{c.business}</p><small>买家:{c.buyer}</small></td>
|
||
<td>{c.cohort}</td><td>{c.team !== null && c.teamBound}{c.team ?? "未披露"}{c.team !== null && " 人"}</td><td>{c.stage}<small>{technicalReviewLabels[c.technicalReview?.level ?? "unreviewed"]}</small></td><td>{c.profileOnly ? "收入尚未逐一核验" : revenueLabels[c.revenueStatus]}</td>
|
||
</tr>)}
|
||
</tbody></table></div>
|
||
{!filtered.length && <p className="research-message">没有匹配公司,请调整筛选条件。</p>}
|
||
{filtered.length > pageSize && <nav className="research-pagination" aria-label="公司数据分页"><button disabled={page === 0} onClick={() => setPage((n) => n - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= filtered.length} onClick={() => setPage((n) => n + 1)}>下一页</button></nav>}
|
||
</>}
|
||
{tab === "models" && <BusinessModelAssessment data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
|
||
{tab === "revenue" && <>
|
||
<IncomeDistribution data={data} onSelect={setSelected} />
|
||
<ArrDisclosures data={data} onSelect={setSelected} />
|
||
<h3>企业披露与估算的历史里程碑</h3>
|
||
<p className="research-notice">这一组包含 {disclosedCompanies} 家公司的披露或估算信号;企业披露含公司或交易当事方公开材料,披露主体与审计状态见证据。多为异时点历史里程碑,期间收入不直接年化,ARR 与年化收入口径仍待核对,暂不计算其同期领域均值。币种未确认的记录仅显示原值,不绘制美元长度条。上面的平台MRR统计与这一组分开。</p>
|
||
<div className="research-revenue-chart" aria-label="不同时间的公开收入里程碑,线性长度,非同行排名">{observed.map((o) => <div key={o.id}>
|
||
<div><button onClick={() => setSelected(o.company)}>{o.name}</button><strong>{o.bound !== "=" && o.bound}{money(o.value, o.unit)}</strong></div>
|
||
{o.unit === "USD" && <span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(1, ...observed.filter((v) => v.unit === "USD").map((v) => v.value)) * 100}%`}} /></span>}
|
||
<small>{o.kind} · {useLabels[o.selection]} · {o.timing}</small>
|
||
</div>)}</div>
|
||
<div className="research-table-wrap"><table><caption>当前研究数据中的收入可得性,目录项未逐一检索;未建立同期、同龄、同口径比较组</caption><thead><tr><th>业务分类</th><th>企业披露数</th><th>仅估算数</th><th>仅平台数</th><th>暂无可用数字</th><th>同期均值 / 离散度</th></tr></thead><tbody>{summaries.map((c) => <tr key={c.id}><td><button onClick={() => chooseCategory(c.id)}>{c.name}</button></td><td>{c.self}</td><td>{c.estimate}</td><td>{c.platform}</td><td>{c.unknown}</td><td>条件不足</td></tr>)}</tbody></table></div>
|
||
<p className="research-muted">离散度用于筛选值得核查的异常,还需排除公司年龄、口径和披露偏差;不能据此认定财务造假。</p>
|
||
</>}
|
||
{tab === "sources" && <><p className="research-muted">来源链接保留原始位置。相同 URL 的记录可能属于同一披露,不能按记录数计算独立证据数量。</p><ul className="research-evidence-list">{data.evidence.slice(sourcePage * evidencePageSize, (sourcePage + 1) * evidencePageSize).map((e) => <Evidence key={e.id} item={e} />)}</ul>{data.evidence.length > evidencePageSize && <nav className="research-pagination" aria-label="原始证据分页"><button disabled={sourcePage === 0} onClick={() => setSourcePage((n) => n - 1)}>上一页</button><span>{sourcePage + 1} / {Math.ceil(data.evidence.length / evidencePageSize)}</span><button disabled={(sourcePage + 1) * evidencePageSize >= data.evidence.length} onClick={() => setSourcePage((n) => n + 1)}>下一页</button></nav>}</>}
|
||
{tab === "reviews" && <ul className="research-review-list">{data.reviews.map((r) => <li key={r.id}><span className="research-tag">{r.type}</span><h3>{r.company}</h3>{r.scope === "dataset" && <small>全局数据方法核查</small>}{r.scope === "excluded-candidate" && <small>未纳入公司表的候选</small>}<p>{r.summary}</p><p>{r.handling}</p><div>{reviewCompanies(r).map((c) => <button key={c.id} className="research-company-name" onClick={() => setSelected(c.id)}>查看 {c.name} 明细</button>)}</div><div>{r.evidenceIds.map((id) => { const e=data.evidence.find((item) => item.id === id); return e ? <External key={id} url={e.url}>{id}</External> : null; })}</div></li>)}</ul>}
|
||
</section>
|
||
{selectedCompany && <section id="research-company-detail" ref={detailSection} tabIndex={-1} className="research-detail research-section" aria-label={`${selectedCompany.name}资料明细`}>
|
||
<div className="research-detail-heading"><h2>{selectedCompany.name}</h2><button onClick={() => setSelected(null)} aria-label="关闭公司明细">关闭</button></div>
|
||
<p><External url={selectedCompany.url}>资料入口</External> {selectedCompany.extraUrl && <External url={selectedCompany.extraUrl}>额外核验入口</External>}</p>
|
||
<dl className="research-company-facts"><div><dt>主营业务</dt><dd>{selectedCompany.business}</dd></div><div><dt>样本组别</dt><dd>{selectedCompany.cohort}</dd></div><div><dt>买家</dt><dd>{selectedCompany.buyer}</dd></div><div><dt>收费/交付模式</dt><dd>{selectedCompany.businessModel ?? "待进一步核验"}</dd></div><div><dt>收费方式</dt><dd>{selectedCompany.price}</dd></div><div><dt>交付证据</dt><dd>{selectedCompany.delivery}</dd></div><div><dt>团队口径</dt><dd>{selectedCompany.teamBasis ?? "本轮未核验人数"}</dd></div><div><dt>分类依据</dt><dd>{selectedCompany.classificationBasis ?? "原有个案分类;随研究复核"}</dd></div><div><dt>技术验证证据</dt><dd>{technicalReviewLabels[selectedCompany.technicalReview?.level ?? "unreviewed"]}<p>{selectedCompany.technicalReview?.summary ?? "本轮未形成专项技术评审记录;不表示公司没有技术能力。"}</p>{selectedCompany.technicalReview && <small>评审日期:{selectedCompany.technicalReview.reviewedAt}</small>}{selectedCompany.technicalReview?.testScope && <p>测试范围:{selectedCompany.technicalReview.testScope}</p>}<p>材料可访问仅确认可查看,不证明性能、可靠性或商业交付质量;不同任务不作统一能力排名。</p></dd></div><div><dt>观测主体</dt><dd>{selectedCompany.entityBasis ?? "公司或经营品牌,法律主体未逐一核验"}</dd></div><div><dt>身份与限制</dt><dd>{selectedCompany.notes}</dd></div></dl>
|
||
<h3>经营与技术观测</h3>
|
||
{!!selectedReviews.length && <details><summary>关联核查记录({selectedReviews.length})</summary><ul>{selectedReviews.map((r) => <li key={r.id}><strong>{r.type}:{r.summary}</strong><p>{r.handling}</p><div>{r.evidenceIds.map((id) => { const item = data.evidence.find((e) => e.id === id); return item ? <External key={id} url={item.url}>{id}</External> : null; })}</div></li>)}</ul></details>}
|
||
{selectedObservations.length ? <div className="research-table-wrap"><table><thead><tr><th>指标 / 归属</th><th>原始数值</th><th>数据时点</th><th>用途及限制</th><th>证据</th></tr></thead><tbody>{selectedObservations.map((o) => <tr key={o.id}><td>{o.kind}<small>{o.scope}</small></td><td>{o.bound !== "=" && o.bound}{money(o.value,o.unit)}</td><td>{o.timing}</td><td>{useLabels[o.selection] ?? o.selection}<small>{o.reason}</small></td><td><a href={`#detail-evidence-${o.evidence}`}>{o.evidence}</a></td></tr>)}</tbody></table></div> : <p>本轮尚未找到可记录的量化经营指标。收入保持未知。</p>}
|
||
<h3>原始证据</h3><ul className="research-evidence-list">{selectedEvidence.map((e) => <Evidence key={e.id} prefix="detail-evidence" item={e} />)}</ul>
|
||
</section>}
|
||
<p className="research-footnote">数据版本 {data.version}。当前为首批资料,覆盖、收入定义、财务透明度与商业模式评估将随核验补充。</p>
|
||
</div>;
|
||
}
|