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

76 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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>;
}