Expose source candidates and unresolved classification gaps
This commit is contained in:
@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
|
||||
import IncomeDistribution from "./IncomeDistribution";
|
||||
import ArrDisclosures from "./ArrDisclosures";
|
||||
import BusinessModelAssessment from "./BusinessModelAssessment";
|
||||
import ResearchCandidates from "./ResearchCandidates";
|
||||
import {
|
||||
categorySummary, evidenceUrl, technicalReviewLabels, reviewCompanyIds, type ResearchEvidence, type ResearchReview, type ResearchSnapshot,
|
||||
} from "@/lib/ai-research";
|
||||
@@ -57,7 +58,7 @@ export default function AICompanyResearch() {
|
||||
: 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 [tab, setTab] = useState<"companies" | "candidates" | "revenue" | "models" | "sources" | "reviews">("companies");
|
||||
const companySection = useRef<HTMLElement>(null);
|
||||
const detailSection = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -129,7 +130,7 @@ export default function AICompanyResearch() {
|
||||
<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]) =>
|
||||
{([['companies','公司与产品'],['candidates','候选与缺项'],['revenue','收入观测'],['models','商业模式初评'],['sources','原始证据'],['reviews','核查记录']] as const).map(([id,label]) =>
|
||||
<button key={id} aria-pressed={tab === id} onClick={() => setTab(id)}>{label}</button>)}
|
||||
</div>
|
||||
{tab === "companies" && <>
|
||||
@@ -155,6 +156,7 @@ export default function AICompanyResearch() {
|
||||
{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 === "candidates" && <ResearchCandidates data={data} onSelect={setSelected} onChooseCategory={chooseCategory} />}
|
||||
{tab === "revenue" && <>
|
||||
<IncomeDistribution data={data} onSelect={setSelected} />
|
||||
<ArrDisclosures data={data} onSelect={setSelected} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { evidenceUrl, type ResearchCandidateBatch, type ResearchSnapshot } from "@/lib/ai-research";
|
||||
|
||||
type CandidateStatus = ResearchCandidateBatch["records"][number]["status"];
|
||||
const statusLabels: Record<CandidateStatus, string> = {
|
||||
"pending-year": "成立年或主体待核", "pending-ai": "AI 主营待核", linked: "已关联公司",
|
||||
"existing-outside-cohort": "已关联范围外对照", "outside-cohort": "范围外候选", promotion: "推广活动",
|
||||
};
|
||||
const pageSize = 20;
|
||||
const sourceNames: Record<string, string> = {
|
||||
S01: "Y Combinator", S02: "TechCrunch AI", S04: "Product Hunt", S05: "Sacra",
|
||||
S10: "机器之心", S11: "36氪", S13: "TrustMRR", S14: "a16z Speedrun",
|
||||
};
|
||||
const sourceLabel = (id: string) => `${id} · ${sourceNames[id] ?? "其他来源"}`;
|
||||
|
||||
function SourceLink({ url, label }: { url: string | null; label: string }) {
|
||||
const safe = url && evidenceUrl(url);
|
||||
return safe ? <a href={safe} target="_blank" rel="noopener noreferrer">{label} ↗</a> : <span>{label}(链接未取得)</span>;
|
||||
}
|
||||
|
||||
export default function ResearchCandidates({ data, onSelect, onChooseCategory }: {
|
||||
data: ResearchSnapshot; onSelect: (id: string) => void; onChooseCategory: (id: string) => void;
|
||||
}) {
|
||||
const [source, setSource] = useState("");
|
||||
const [batchId, setBatchId] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [status, setStatus] = useState<CandidateStatus | "pending" | "">("pending");
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const all = useMemo(() => (data.candidateBatches ?? []).flatMap((batch) => batch.records.map((record) => ({ batch, record }))), [data.candidateBatches]);
|
||||
const sources = [...new Set(all.map(({ batch }) => batch.sourceId))].sort();
|
||||
const batches = (data.candidateBatches ?? []).filter((batch) => !source || batch.sourceId === source);
|
||||
const matched = all.filter(({ batch, record }) => (!source || batch.sourceId === source)
|
||||
&& (!batchId || batch.id === batchId) && (!category || record.categoryId === category)
|
||||
&& `${record.name} ${record.id} ${record.note}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
const pending = (value: CandidateStatus) => value === "pending-year" || value === "pending-ai";
|
||||
const rows = matched.filter(({ record }) => !status || (status === "pending" ? pending(record.status) : record.status === status));
|
||||
const companies = new Map(data.companies.map((company) => [company.id, company]));
|
||||
const evidence = new Map(data.evidence.map((item) => [item.id, item]));
|
||||
const categories = new Map(data.categories.map((item) => [item.id, item.name]));
|
||||
return <section aria-labelledby="research-candidates-title">
|
||||
<h3 id="research-candidates-title">候选与缺项</h3>
|
||||
<p>展示已登记的有限来源批次及后续核查。默认显示成立年、主体或 AI 主营待核的条目;待核不是确认排除,也不计入公司表。每行是一次发现位置,同一公司或产品可以重复出现。</p>
|
||||
<p className="research-muted">候选初分类与公司表数量分开,来源批次不等全年或全站名单。原始发现范围及后续结论见每行说明;暂无收入资料不作零,推广活动也不算公司。</p>
|
||||
<div className="research-filters">
|
||||
<label className="research-query">搜索候选<input type="search" value={query} placeholder="名称、编号或核查说明" onChange={(event) => { setQuery(event.target.value); setPage(0); }} /></label>
|
||||
<label>发现来源<select value={source} onChange={(event) => { setSource(event.target.value); setBatchId(""); setPage(0); }}><option value="">全部已登记来源</option>{sources.map((id) => <option key={id} value={id}>{sourceLabel(id)}</option>)}</select></label>
|
||||
<label>发现批次<select value={batchId} onChange={(event) => { setBatchId(event.target.value); setPage(0); }}><option value="">全部批次</option>{batches.map((batch) => <option key={batch.id} value={batch.id}>{batch.id}</option>)}</select></label>
|
||||
<label>候选初分类<select value={category} onChange={(event) => { setCategory(event.target.value); setPage(0); }}><option value="">全部分类</option>{data.categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
|
||||
<label>核查状态<select value={status} onChange={(event) => { setStatus(event.target.value as typeof status); setPage(0); }}>
|
||||
<option value="">全部状态({matched.length})</option><option value="pending">全部待核({matched.filter(({ record }) => pending(record.status)).length})</option>
|
||||
{Object.entries(statusLabels).map(([id, label]) => <option key={id} value={id}>{label}({matched.filter(({ record }) => record.status === id).length})</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<p role="status">{rows.length} 个发现位置匹配;不是独立公司数。</p>
|
||||
<div className="research-table-wrap"><table>
|
||||
<caption>有限来源候选与处理记录;选择关联公司查看主数据,原始证据直接链接来源</caption>
|
||||
<thead><tr><th scope="col">候选</th><th scope="col">初分类 / 状态</th><th scope="col">来源及范围</th><th scope="col">核查结论 / 缺项</th><th scope="col">关联公司 / 证据</th></tr></thead>
|
||||
<tbody>{rows.slice(page * pageSize, (page + 1) * pageSize).map(({ batch, record }) => {
|
||||
const company = record.companyId && companies.get(record.companyId);
|
||||
return <tr key={record.id}>
|
||||
<td><SourceLink url={record.url} label={record.name} /><small>{record.id}</small></td>
|
||||
<td><button onClick={() => onChooseCategory(record.categoryId)} aria-label={`查看${categories.get(record.categoryId)}分类内的公司`}>{categories.get(record.categoryId)}</button><small>{statusLabels[record.status]}</small></td>
|
||||
<td><SourceLink url={batch.url} label={sourceLabel(batch.sourceId)} /><small>{batch.id} · 读取 {batch.accessedAt}</small><details><summary>发现批次范围</summary><p>{batch.scope}</p><p>{batch.complete ? "该批次标记已完成;不代表来源全站覆盖。" : "该批次仍保留覆盖缺项。"}</p></details></td>
|
||||
<td>{record.note}</td>
|
||||
<td>{company ? <button className="research-company-name" onClick={() => onSelect(company.id)}>{company.name}</button> : <small>尚未关联公司主记录</small>}
|
||||
{record.evidenceIds?.map((id) => { const item = evidence.get(id); return item ? <p key={id}><SourceLink url={item.url} label={id} /></p> : null; })}
|
||||
</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div>
|
||||
{!rows.length && <p>当前筛选没有已登记的候选记录;不代表该来源或分类没有公司。</p>}
|
||||
{rows.length > pageSize && <nav className="research-pagination" aria-label="候选与缺项分页"><button disabled={page === 0} onClick={() => setPage((value) => value - 1)}>上一页</button><span>{page + 1} / {Math.ceil(rows.length / pageSize)}</span><button disabled={(page + 1) * pageSize >= rows.length} onClick={() => setPage((value) => value + 1)}>下一页</button></nav>}
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user