HotContentSourcePage.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import { useEffect, useMemo, useState } from "react";
  2. import { Alert, Button, Skeleton, Table, Tag, Typography } from "antd";
  3. import type { ColumnsType } from "antd/es/table";
  4. const API_BASE_URL =
  5. import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
  6. const BODY_PREVIEW_LENGTH = 160;
  7. const getResolvedApiBaseUrl = () => {
  8. if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
  9. return API_BASE_URL;
  10. }
  11. return new URL(API_BASE_URL, window.location.origin).toString();
  12. };
  13. type ExportRow = {
  14. id: number;
  15. item_type: string;
  16. item_text: string;
  17. point_category: string;
  18. matched_demand: string;
  19. contribution_score: number | null;
  20. wxindex_keyword: string;
  21. all_hot_keywords: string;
  22. wxindex_latest_score: number;
  23. wxindex_trend: string;
  24. contributes_to_sync: boolean;
  25. };
  26. type HotContentSourceDetail = {
  27. wxindex_threshold: number;
  28. sync_log: {
  29. id: number;
  30. partition_dt: string;
  31. strategy: string;
  32. demand_id: string;
  33. demand_name: string;
  34. demand_type: string;
  35. record_id: number;
  36. synced_at: string;
  37. demand_id_verified: boolean;
  38. hive_weight: number | null;
  39. };
  40. record: {
  41. id: number;
  42. source: string;
  43. title: string;
  44. article_title: string;
  45. article_body: string;
  46. hot_rank: number | null;
  47. created_at: string;
  48. contribution: Record<string, unknown>;
  49. wxindex: Record<string, unknown>;
  50. max_wxindex_score: number;
  51. passes_wxindex_gate: boolean;
  52. passes_point_gate: boolean;
  53. };
  54. export_rows: ExportRow[];
  55. };
  56. function formatNumber(value: unknown): string {
  57. const number = Number(value);
  58. if (!Number.isFinite(number)) {
  59. return "-";
  60. }
  61. if (Math.abs(number) >= 10000) {
  62. return `${(number / 10000).toFixed(1)}万`;
  63. }
  64. return String(Math.round(number));
  65. }
  66. function formatScore(value: unknown): string {
  67. const number = Number(value);
  68. if (!Number.isFinite(number)) {
  69. return "-";
  70. }
  71. return String(Number(number.toFixed(2)));
  72. }
  73. function formatPartitionDt(value: string): string {
  74. const text = value.trim();
  75. if (/^\d{8}$/.test(text)) {
  76. return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6)}`;
  77. }
  78. return text || "-";
  79. }
  80. function renderContributionSummary(contribution: Record<string, unknown>) {
  81. const words = contribution["高贡献词列表"];
  82. const points = contribution["点列表"];
  83. let matchedWords = 0;
  84. if (Array.isArray(words)) {
  85. for (const item of words) {
  86. if (
  87. item &&
  88. typeof item === "object" &&
  89. Array.isArray((item as Record<string, unknown>)["匹配需求列表"]) &&
  90. ((item as Record<string, unknown>)["匹配需求列表"] as unknown[]).length > 0
  91. ) {
  92. matchedWords += 1;
  93. }
  94. }
  95. }
  96. const wordCount = Array.isArray(words) ? words.length : 0;
  97. const pointCount = Array.isArray(points) ? points.length : 0;
  98. return (
  99. <div className="hot-source-mini-metrics">
  100. <span>高贡献词 {wordCount} 个</span>
  101. <span>匹配到需求 {matchedWords} 个</span>
  102. <span>点列表 {pointCount} 个</span>
  103. </div>
  104. );
  105. }
  106. function renderWxindexSummary(wxindex: Record<string, unknown>) {
  107. const wx =
  108. wxindex.wxindex && typeof wxindex.wxindex === "object"
  109. ? (wxindex.wxindex as Record<string, unknown>)
  110. : {};
  111. const keyword = String(wx.keyword ?? wxindex.llm_selected_word ?? "-");
  112. const trend = String(wx.trend ?? "-");
  113. const latest = formatNumber(wx.latest_total_score);
  114. const reason = String(wxindex.llm_reason ?? "").trim();
  115. return (
  116. <>
  117. <div className="hot-source-mini-metrics">
  118. <span>
  119. 关键词 <b>{keyword}</b>
  120. </span>
  121. <span>
  122. 趋势 <b>{trend}</b>
  123. </span>
  124. <span>
  125. 当前得分 <b>{latest}</b>
  126. </span>
  127. </div>
  128. <div className="hot-source-mini-reason">{reason || "暂无 LLM 选词理由"}</div>
  129. </>
  130. );
  131. }
  132. type HotContentSourcePageProps = {
  133. demandName: string;
  134. demandType: string;
  135. dt: string;
  136. onBack: () => void;
  137. };
  138. export default function HotContentSourcePage({
  139. demandName,
  140. demandType,
  141. dt,
  142. onBack,
  143. }: HotContentSourcePageProps) {
  144. const [loading, setLoading] = useState(true);
  145. const [error, setError] = useState("");
  146. const [detail, setDetail] = useState<HotContentSourceDetail | null>(null);
  147. const [bodyExpanded, setBodyExpanded] = useState(false);
  148. useEffect(() => {
  149. let cancelled = false;
  150. const load = async () => {
  151. setLoading(true);
  152. setError("");
  153. setDetail(null);
  154. const resolvedBase = getResolvedApiBaseUrl();
  155. const baseWithSlash = resolvedBase.endsWith("/")
  156. ? resolvedBase
  157. : `${resolvedBase}/`;
  158. const url = new URL("demand-pool/hot-content-source", baseWithSlash);
  159. url.searchParams.set("demand_name", demandName);
  160. url.searchParams.set("demand_type", demandType);
  161. url.searchParams.set("dt", dt);
  162. try {
  163. const response = await fetch(url.toString(), {
  164. method: "GET",
  165. headers: { Accept: "application/json" },
  166. });
  167. const payload = (await response.json()) as { detail?: string };
  168. if (!response.ok) {
  169. throw new Error(payload.detail ?? `HTTP ${response.status}`);
  170. }
  171. if (!cancelled) {
  172. setDetail(payload as HotContentSourceDetail);
  173. }
  174. } catch (loadError) {
  175. if (!cancelled) {
  176. setError(
  177. loadError instanceof Error ? loadError.message : "加载失败,请重试",
  178. );
  179. }
  180. } finally {
  181. if (!cancelled) {
  182. setLoading(false);
  183. }
  184. }
  185. };
  186. void load();
  187. return () => {
  188. cancelled = true;
  189. };
  190. }, [demandName, demandType, dt]);
  191. const exportColumns: ColumnsType<ExportRow> = useMemo(
  192. () => [
  193. { title: "ID", dataIndex: "id", width: 72 },
  194. { title: "类型", dataIndex: "item_type", width: 72 },
  195. {
  196. title: "词条",
  197. dataIndex: "item_text",
  198. render: (value) => <b>{value || "-"}</b>,
  199. },
  200. {
  201. title: "点类型",
  202. dataIndex: "point_category",
  203. width: 96,
  204. render: (value) => value || "-",
  205. },
  206. {
  207. title: "匹配需求",
  208. dataIndex: "matched_demand",
  209. render: (value) => value || "-",
  210. },
  211. {
  212. title: "微信热点词",
  213. dataIndex: "wxindex_keyword",
  214. width: 120,
  215. render: (value) => value || "-",
  216. },
  217. {
  218. title: "贡献分",
  219. dataIndex: "contribution_score",
  220. width: 88,
  221. render: (value) => (value == null ? "-" : formatScore(value)),
  222. },
  223. {
  224. title: "微信指数",
  225. dataIndex: "wxindex_latest_score",
  226. width: 96,
  227. render: (value) => formatNumber(value),
  228. },
  229. {
  230. title: "趋势",
  231. dataIndex: "wxindex_trend",
  232. width: 88,
  233. render: (value) => value || "-",
  234. },
  235. {
  236. title: "状态",
  237. key: "status",
  238. width: 100,
  239. render: (_, row) => {
  240. const matched = Boolean(row.matched_demand?.trim());
  241. return (
  242. <Tag color={matched ? "success" : "default"}>
  243. {matched ? "匹配需求" : "未匹配"}
  244. </Tag>
  245. );
  246. },
  247. },
  248. ],
  249. [],
  250. );
  251. const record = detail?.record;
  252. const syncLog = detail?.sync_log;
  253. const articleBodyText = useMemo(
  254. () => (record?.article_body ?? "").replace(/\s+/g, " ").trim(),
  255. [record?.article_body],
  256. );
  257. useEffect(() => {
  258. setBodyExpanded(false);
  259. }, [record?.id]);
  260. const bodyTruncated = articleBodyText.length > BODY_PREVIEW_LENGTH;
  261. const displayBody = !articleBodyText
  262. ? "暂无正文"
  263. : bodyExpanded || !bodyTruncated
  264. ? articleBodyText
  265. : `${articleBodyText.slice(0, BODY_PREVIEW_LENGTH)}…`;
  266. return (
  267. <div className="page hot-source-page">
  268. <div className="hero hot-source-hero">
  269. <Button type="link" className="hot-source-back" onClick={onBack}>
  270. ← 返回需求池
  271. </Button>
  272. <Typography.Title level={2} className="hero-title">
  273. 新热事件需求来源
  274. </Typography.Title>
  275. <Typography.Paragraph type="secondary" className="hot-source-subtitle">
  276. 需求:{demandName} · 类型:{demandType} · 分区:{formatPartitionDt(dt)}
  277. </Typography.Paragraph>
  278. </div>
  279. {loading ? (
  280. <div className="hot-source-card-shell">
  281. <Skeleton active paragraph={{ rows: 12 }} />
  282. </div>
  283. ) : null}
  284. {!loading && error ? (
  285. <Alert type="error" showIcon message={error} className="hot-source-alert" />
  286. ) : null}
  287. {!loading && !error && detail && record && syncLog ? (
  288. <article className="hot-source-title-card">
  289. <header className="hot-source-title-header">
  290. <div>
  291. <Typography.Title level={3} className="hot-source-title-text">
  292. {record.title || syncLog.demand_name}
  293. </Typography.Title>
  294. <div className="hot-source-title-meta">
  295. <span>{record.source || "-"}</span>
  296. <span>record #{record.id}</span>
  297. <span>同步于 {syncLog.synced_at}</span>
  298. </div>
  299. <div className="hot-source-badges">
  300. <Tag color={record.passes_wxindex_gate ? "success" : "warning"}>
  301. 微信指数{record.passes_wxindex_gate ? "达标" : "未达标"}
  302. </Tag>
  303. <Tag color={record.passes_point_gate ? "success" : "warning"}>
  304. 灵感/目的点{record.passes_point_gate ? "有匹配" : "无匹配"}
  305. </Tag>
  306. <Tag color={syncLog.demand_id_verified ? "success" : "error"}>
  307. demand_id{syncLog.demand_id_verified ? "校验通过" : "校验失败"}
  308. </Tag>
  309. </div>
  310. </div>
  311. </header>
  312. <section className="hot-source-stage">
  313. <div className="hot-source-sync-meta">
  314. <span>
  315. 策略 <b>{syncLog.strategy}</b>
  316. </span>
  317. <span>
  318. 分区 <b>{formatPartitionDt(syncLog.partition_dt)}</b>
  319. </span>
  320. <span>
  321. 需求类型 <b>{syncLog.demand_type}</b>
  322. </span>
  323. <span>
  324. Hive 权重 <b>{syncLog.hive_weight == null ? "-" : formatScore(syncLog.hive_weight)}</b>
  325. </span>
  326. <span>
  327. 门槛 <b>{formatNumber(detail.wxindex_threshold)}</b>
  328. </span>
  329. </div>
  330. <div className="hot-source-record-subtitle">{record.article_title || "-"}</div>
  331. <div
  332. className={
  333. bodyExpanded
  334. ? "hot-source-record-preview hot-source-record-preview--expanded"
  335. : "hot-source-record-preview"
  336. }
  337. >
  338. {displayBody}
  339. </div>
  340. {bodyTruncated ? (
  341. <Button
  342. type="link"
  343. size="small"
  344. className="hot-source-body-toggle"
  345. onClick={() => setBodyExpanded((value) => !value)}
  346. >
  347. {bodyExpanded ? "收起正文" : "展开全文"}
  348. </Button>
  349. ) : null}
  350. <Typography.Title level={5} className="hot-source-section-label">
  351. 贡献匹配摘要
  352. </Typography.Title>
  353. {renderContributionSummary(record.contribution)}
  354. <Typography.Title level={5} className="hot-source-section-label">
  355. 微信指数摘要
  356. </Typography.Title>
  357. {renderWxindexSummary(record.wxindex)}
  358. </section>
  359. <section className="hot-source-stage">
  360. <Typography.Title level={4} className="hot-source-stage-title">
  361. 元素/短语
  362. </Typography.Title>
  363. <p className="hot-source-stage-hint">
  364. 高亮行表示参与生成该需求词的导出行。
  365. </p>
  366. <Table
  367. rowKey="id"
  368. size="small"
  369. pagination={false}
  370. columns={exportColumns}
  371. dataSource={detail.export_rows}
  372. scroll={{ x: 1040 }}
  373. rowClassName={(row) =>
  374. row.contributes_to_sync ? "hot-source-export-highlight" : ""
  375. }
  376. />
  377. </section>
  378. </article>
  379. ) : null}
  380. </div>
  381. );
  382. }