Files
endorphin-tabularium-docs/apps/docs/src/components/AICompanyResearch.tsx
T

186 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}