Files
endorphin-tabularium-docs/apps/docs/src/components/AICompanyResearch.tsx
T
codex-bot 1250b674b0
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m57s
Add AI company research workbench backed by the shared site database
2026-10-30 03:25:38 +08:00

146 lines
14 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 {
categorySummary, evidenceUrl, type ResearchEvidence, type ResearchSnapshot,
} from "@/lib/ai-research";
const pageSize = 12;
const revenueLabels = { self: "公司披露,待核口径", estimate: "仅第三方估算", unknown: "本轮未找到收入数字" };
const useLabels: Record<string, string> = {
signal: "公司披露信号", estimate: "第三方估算", historical: "历史里程碑",
"historical-estimate": "历史估算", excluded: "排除收入比较", scale: "团队规模",
funding: "融资", 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 [data, setData] = useState<ResearchSnapshot | null>(null);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [cohort, setCohort] = useState("");
const [income, setIncome] = useState("");
const [page, setPage] = useState(0);
const [selected, setSelected] = useState<string | null>(null);
const [tab, setTab] = useState<"companies" | "revenue" | "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));
return (!category || c.cat === category) && yearMatch && (!income || c.revenueStatus === income)
&& search.includes(query.trim().toLocaleLowerCase());
}) ?? [], [data, query, category, cohort, income]);
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 revenueCompanies = new Set(observed.map((o) => o.company)).size;
const selectedObservations = data.observations.filter((o) => o.company === selected);
const selectedEvidenceIds = new Set([
...(selectedCompany?.evidenceIds ?? []), ...(selectedCompany?.pricingEvidence ? [selectedCompany.pricingEvidence] : []),
...selectedObservations.map((o) => o.evidence),
]);
const selectedEvidence = data.evidence.filter((e) => e.companyId === selected || selectedEvidenceIds.has(e.id));
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>
<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','收入观测'],['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={income} onChange={(e) => { setIncome(e.target.value); setPage(0); }}><option value="">全部状态</option><option value="self">找到公司披露</option><option value="estimate">仅第三方估算</option><option value="unknown">本轮未找到数字</option></select></label>
</div>
<div className="research-result-count" role="status">{filtered.length} 家匹配公司<button onClick={() => { setCategory(""); setQuery(""); setCohort(""); setIncome(""); 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></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><p>{c.business}</p><small>买家:{c.buyer}</small></td>
<td>{c.cohort}</td><td>{c.team ?? "未披露"}{c.team !== null && " 人"}</td><td>{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 === "revenue" && <>
<p className="research-notice">目前找到 {revenueCompanies} 家公司的收入信号。大多是缺少日历时点的历史里程碑,且 ARR 与年化收入尚未统一。领域均值和离散度暂不计算。</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>{money(o.value, o.unit)}</strong></div>
<span className="research-bar" data-estimate={o.selection === "estimate" || undefined} aria-hidden="true"><i style={{width: `${o.value / Math.max(...observed.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></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.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.map((e) => <Evidence key={e.id} item={e} />)}</ul></>}
{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><p>{r.summary}</p><p>{r.handling}</p><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.price}</dd></div><div><dt>交付证据</dt><dd>{selectedCompany.delivery}</dd></div><div><dt>团队口径</dt><dd>{selectedCompany.teamBasis ?? "本轮未核验人数"}</dd></div><div><dt>技术水平</dt><dd>尚未独立测试,不据融资或收入作能力排名。</dd></div><div><dt>身份与限制</dt><dd>{selectedCompany.notes}</dd></div></dl>
<h3>经营指标与排除记录</h3>
{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>;
}