| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406 |
- import { useEffect, useMemo, useState } from "react";
- import { Alert, Button, Skeleton, Table, Tag, Typography } from "antd";
- import type { ColumnsType } from "antd/es/table";
- const API_BASE_URL =
- import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
- const BODY_PREVIEW_LENGTH = 160;
- const getResolvedApiBaseUrl = () => {
- if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
- return API_BASE_URL;
- }
- return new URL(API_BASE_URL, window.location.origin).toString();
- };
- type ExportRow = {
- id: number;
- item_type: string;
- item_text: string;
- point_category: string;
- matched_demand: string;
- contribution_score: number | null;
- wxindex_keyword: string;
- all_hot_keywords: string;
- wxindex_latest_score: number;
- wxindex_trend: string;
- contributes_to_sync: boolean;
- };
- type HotContentSourceDetail = {
- wxindex_threshold: number;
- sync_log: {
- id: number;
- partition_dt: string;
- strategy: string;
- demand_id: string;
- demand_name: string;
- demand_type: string;
- record_id: number;
- synced_at: string;
- demand_id_verified: boolean;
- hive_weight: number | null;
- };
- record: {
- id: number;
- source: string;
- title: string;
- article_title: string;
- article_body: string;
- hot_rank: number | null;
- created_at: string;
- contribution: Record<string, unknown>;
- wxindex: Record<string, unknown>;
- max_wxindex_score: number;
- passes_wxindex_gate: boolean;
- passes_point_gate: boolean;
- };
- export_rows: ExportRow[];
- };
- function formatNumber(value: unknown): string {
- const number = Number(value);
- if (!Number.isFinite(number)) {
- return "-";
- }
- if (Math.abs(number) >= 10000) {
- return `${(number / 10000).toFixed(1)}万`;
- }
- return String(Math.round(number));
- }
- function formatScore(value: unknown): string {
- const number = Number(value);
- if (!Number.isFinite(number)) {
- return "-";
- }
- return String(Number(number.toFixed(2)));
- }
- function formatPartitionDt(value: string): string {
- const text = value.trim();
- if (/^\d{8}$/.test(text)) {
- return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6)}`;
- }
- return text || "-";
- }
- function renderContributionSummary(contribution: Record<string, unknown>) {
- const words = contribution["高贡献词列表"];
- const points = contribution["点列表"];
- let matchedWords = 0;
- if (Array.isArray(words)) {
- for (const item of words) {
- if (
- item &&
- typeof item === "object" &&
- Array.isArray((item as Record<string, unknown>)["匹配需求列表"]) &&
- ((item as Record<string, unknown>)["匹配需求列表"] as unknown[]).length > 0
- ) {
- matchedWords += 1;
- }
- }
- }
- const wordCount = Array.isArray(words) ? words.length : 0;
- const pointCount = Array.isArray(points) ? points.length : 0;
- return (
- <div className="hot-source-mini-metrics">
- <span>高贡献词 {wordCount} 个</span>
- <span>匹配到需求 {matchedWords} 个</span>
- <span>点列表 {pointCount} 个</span>
- </div>
- );
- }
- function renderWxindexSummary(wxindex: Record<string, unknown>) {
- const wx =
- wxindex.wxindex && typeof wxindex.wxindex === "object"
- ? (wxindex.wxindex as Record<string, unknown>)
- : {};
- const keyword = String(wx.keyword ?? wxindex.llm_selected_word ?? "-");
- const trend = String(wx.trend ?? "-");
- const latest = formatNumber(wx.latest_total_score);
- const reason = String(wxindex.llm_reason ?? "").trim();
- return (
- <>
- <div className="hot-source-mini-metrics">
- <span>
- 关键词 <b>{keyword}</b>
- </span>
- <span>
- 趋势 <b>{trend}</b>
- </span>
- <span>
- 当前得分 <b>{latest}</b>
- </span>
- </div>
- <div className="hot-source-mini-reason">{reason || "暂无 LLM 选词理由"}</div>
- </>
- );
- }
- type HotContentSourcePageProps = {
- demandName: string;
- demandType: string;
- dt: string;
- onBack: () => void;
- };
- export default function HotContentSourcePage({
- demandName,
- demandType,
- dt,
- onBack,
- }: HotContentSourcePageProps) {
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState("");
- const [detail, setDetail] = useState<HotContentSourceDetail | null>(null);
- const [bodyExpanded, setBodyExpanded] = useState(false);
- useEffect(() => {
- let cancelled = false;
- const load = async () => {
- setLoading(true);
- setError("");
- setDetail(null);
- const resolvedBase = getResolvedApiBaseUrl();
- const baseWithSlash = resolvedBase.endsWith("/")
- ? resolvedBase
- : `${resolvedBase}/`;
- const url = new URL("demand-pool/hot-content-source", baseWithSlash);
- url.searchParams.set("demand_name", demandName);
- url.searchParams.set("demand_type", demandType);
- url.searchParams.set("dt", dt);
- try {
- const response = await fetch(url.toString(), {
- method: "GET",
- headers: { Accept: "application/json" },
- });
- const payload = (await response.json()) as { detail?: string };
- if (!response.ok) {
- throw new Error(payload.detail ?? `HTTP ${response.status}`);
- }
- if (!cancelled) {
- setDetail(payload as HotContentSourceDetail);
- }
- } catch (loadError) {
- if (!cancelled) {
- setError(
- loadError instanceof Error ? loadError.message : "加载失败,请重试",
- );
- }
- } finally {
- if (!cancelled) {
- setLoading(false);
- }
- }
- };
- void load();
- return () => {
- cancelled = true;
- };
- }, [demandName, demandType, dt]);
- const exportColumns: ColumnsType<ExportRow> = useMemo(
- () => [
- { title: "ID", dataIndex: "id", width: 72 },
- { title: "类型", dataIndex: "item_type", width: 72 },
- {
- title: "词条",
- dataIndex: "item_text",
- render: (value) => <b>{value || "-"}</b>,
- },
- {
- title: "点类型",
- dataIndex: "point_category",
- width: 96,
- render: (value) => value || "-",
- },
- {
- title: "匹配需求",
- dataIndex: "matched_demand",
- render: (value) => value || "-",
- },
- {
- title: "微信热点词",
- dataIndex: "wxindex_keyword",
- width: 120,
- render: (value) => value || "-",
- },
- {
- title: "贡献分",
- dataIndex: "contribution_score",
- width: 88,
- render: (value) => (value == null ? "-" : formatScore(value)),
- },
- {
- title: "微信指数",
- dataIndex: "wxindex_latest_score",
- width: 96,
- render: (value) => formatNumber(value),
- },
- {
- title: "趋势",
- dataIndex: "wxindex_trend",
- width: 88,
- render: (value) => value || "-",
- },
- {
- title: "状态",
- key: "status",
- width: 100,
- render: (_, row) => {
- const matched = Boolean(row.matched_demand?.trim());
- return (
- <Tag color={matched ? "success" : "default"}>
- {matched ? "匹配需求" : "未匹配"}
- </Tag>
- );
- },
- },
- ],
- [],
- );
- const record = detail?.record;
- const syncLog = detail?.sync_log;
- const articleBodyText = useMemo(
- () => (record?.article_body ?? "").replace(/\s+/g, " ").trim(),
- [record?.article_body],
- );
- useEffect(() => {
- setBodyExpanded(false);
- }, [record?.id]);
- const bodyTruncated = articleBodyText.length > BODY_PREVIEW_LENGTH;
- const displayBody = !articleBodyText
- ? "暂无正文"
- : bodyExpanded || !bodyTruncated
- ? articleBodyText
- : `${articleBodyText.slice(0, BODY_PREVIEW_LENGTH)}…`;
- return (
- <div className="page hot-source-page">
- <div className="hero hot-source-hero">
- <Button type="link" className="hot-source-back" onClick={onBack}>
- ← 返回需求池
- </Button>
- <Typography.Title level={2} className="hero-title">
- 新热事件需求来源
- </Typography.Title>
- <Typography.Paragraph type="secondary" className="hot-source-subtitle">
- 需求:{demandName} · 类型:{demandType} · 分区:{formatPartitionDt(dt)}
- </Typography.Paragraph>
- </div>
- {loading ? (
- <div className="hot-source-card-shell">
- <Skeleton active paragraph={{ rows: 12 }} />
- </div>
- ) : null}
- {!loading && error ? (
- <Alert type="error" showIcon message={error} className="hot-source-alert" />
- ) : null}
- {!loading && !error && detail && record && syncLog ? (
- <article className="hot-source-title-card">
- <header className="hot-source-title-header">
- <div>
- <Typography.Title level={3} className="hot-source-title-text">
- {record.title || syncLog.demand_name}
- </Typography.Title>
- <div className="hot-source-title-meta">
- <span>{record.source || "-"}</span>
- <span>record #{record.id}</span>
- <span>同步于 {syncLog.synced_at}</span>
- </div>
- <div className="hot-source-badges">
- <Tag color={record.passes_wxindex_gate ? "success" : "warning"}>
- 微信指数{record.passes_wxindex_gate ? "达标" : "未达标"}
- </Tag>
- <Tag color={record.passes_point_gate ? "success" : "warning"}>
- 灵感/目的点{record.passes_point_gate ? "有匹配" : "无匹配"}
- </Tag>
- <Tag color={syncLog.demand_id_verified ? "success" : "error"}>
- demand_id{syncLog.demand_id_verified ? "校验通过" : "校验失败"}
- </Tag>
- </div>
- </div>
- </header>
- <section className="hot-source-stage">
- <div className="hot-source-sync-meta">
- <span>
- 策略 <b>{syncLog.strategy}</b>
- </span>
- <span>
- 分区 <b>{formatPartitionDt(syncLog.partition_dt)}</b>
- </span>
- <span>
- 需求类型 <b>{syncLog.demand_type}</b>
- </span>
- <span>
- Hive 权重 <b>{syncLog.hive_weight == null ? "-" : formatScore(syncLog.hive_weight)}</b>
- </span>
- <span>
- 门槛 <b>{formatNumber(detail.wxindex_threshold)}</b>
- </span>
- </div>
- <div className="hot-source-record-subtitle">{record.article_title || "-"}</div>
- <div
- className={
- bodyExpanded
- ? "hot-source-record-preview hot-source-record-preview--expanded"
- : "hot-source-record-preview"
- }
- >
- {displayBody}
- </div>
- {bodyTruncated ? (
- <Button
- type="link"
- size="small"
- className="hot-source-body-toggle"
- onClick={() => setBodyExpanded((value) => !value)}
- >
- {bodyExpanded ? "收起正文" : "展开全文"}
- </Button>
- ) : null}
- <Typography.Title level={5} className="hot-source-section-label">
- 贡献匹配摘要
- </Typography.Title>
- {renderContributionSummary(record.contribution)}
- <Typography.Title level={5} className="hot-source-section-label">
- 微信指数摘要
- </Typography.Title>
- {renderWxindexSummary(record.wxindex)}
- </section>
- <section className="hot-source-stage">
- <Typography.Title level={4} className="hot-source-stage-title">
- 元素/短语
- </Typography.Title>
- <p className="hot-source-stage-hint">
- 高亮行表示参与生成该需求词的导出行。
- </p>
- <Table
- rowKey="id"
- size="small"
- pagination={false}
- columns={exportColumns}
- dataSource={detail.export_rows}
- scroll={{ x: 1040 }}
- rowClassName={(row) =>
- row.contributes_to_sync ? "hot-source-export-highlight" : ""
- }
- />
- </section>
- </article>
- ) : null}
- </div>
- );
- }
|