本提交为 dsh_mcn_workshop 仓库的首个提交(旧仓 dsh_MCNProject 已停用), 完整固化 C:/Users/Administrator/.dsh 当前磁盘状态。 1) 临时数据清理:移出 sessions/(81M)、logs/、skill-backup-20260906/(14M)、 scripts/__pycache__、storages/workspace.json.bak —— 合计 94M(.dsh 162M → 68M) 2) 凭据脱敏:.credentials.yaml(含明文 DEEPSEEK_API_KEY)移出 git 跟踪并加入 .gitignore;mcn-data-insight/scripts/setup_redfox_key.py 中真实 key 示例改为占位符 3) .gitignore 增补 logs/、skill-backup-*/ 规则 4) 技能结构以当前磁盘状态为准:skills/ = mcn-short-video / impeccable / taste-skill / dsh-multi-user-migration;旧技能目录(mcn-dou-analysis、storyboard-prompt、 short-video-script、browser-harness、mcn-data-insight)与 profiles/web/node_modules/dsh-vision-router 本地已不存在,本提交记为删除
497 lines
27 KiB
JavaScript
497 lines
27 KiB
JavaScript
window.__ModuleLoader__.load({
|
||
id: "dsh-plugin-douyin-video-detail",
|
||
factory: (require) => {
|
||
var module = { exports: {} };
|
||
var exports = module.exports;
|
||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||
let react = require("react");
|
||
let react_dom = require("react-dom");
|
||
//#region plugin css(暗色适配:嵌套变量 + 无浅色 fallback,见踩坑手册 3.4)
|
||
const css = ".dvv_page{position:relative;background:var(--dsw-alias-bg-base);width:100%;min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex:1;display:grid;grid-template-rows:minmax(0,1fr);overflow:hidden}.dvv_body{display:flex;flex-direction:column;height:100%;min-height:0;padding:16px;box-sizing:border-box;gap:12px}.dvv_toolbar{display:flex;align-items:center;gap:10px;min-width:0}.dvv_btn{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 14px;font:var(--dsw-font-s-14);cursor:pointer;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);transition:background var(--ds-transition-duration-fast) var(--ds-ease-in-out)}.dvv_btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dvv_title{font:var(--dsw-font-l-16);font-weight:600;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.dvv_link{color:var(--dsw-alias-accent-fg);cursor:pointer;text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvv_link:hover{text-decoration:underline}.dvv_infoCard{flex:0 0 auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))}.dvv_infoGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px 18px}.dvv_infoItem{display:flex;flex-direction:column;gap:4px;min-width:0}.dvv_infoItemWide{grid-column:1/-1}.dvv_infoLabel{font:var(--dsw-font-s-12);color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvv_infoValue{font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary);word-break:break-word;white-space:pre-wrap;line-height:1.6}.dvv_analysisHead{flex:0 0 auto;font:var(--dsw-font-l-15);font-weight:600;color:var(--dsw-alias-label-primary)}.dvv_analysisWrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));box-sizing:border-box}.dvv_state{padding:28px 16px;text-align:center;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));font:var(--dsw-font-s-14)}.dvv_loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvv_error{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-danger-fg,var(--dsw-alias-error-fg))}.dvv_empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvv_md{font:var(--dsw-font-s-14);line-height:1.75;color:var(--dsw-alias-label-primary);word-break:break-word}.dvv_md h1,.dvv_md h2,.dvv_md h3,.dvv_md h4{margin:18px 0 8px;font-weight:600;line-height:1.4}.dvv_md h1{font-size:20px;margin-top:26px}.dvv_md h2{font-size:17px;margin-top:24px}.dvv_md h3{font-size:15px;margin-top:20px}.dvv_md h4{font-size:14px;margin-top:18px}.dvv_md p{margin:8px 0}.dvv_md ul,.dvv_md ol{margin:8px 0;padding-left:22px}.dvv_md li{margin:2px 0}.dvv_md a{color:var(--dsw-alias-accent-fg);text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvv_md a:hover{text-decoration:underline}.dvv_md strong{font-weight:600}.dvv_md em{font-style:italic}.dvv_md code{font-family:var(--dsw-font-mono,monospace);font-size:.9em;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:4px;padding:1px 5px}.dvv_md pre{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:12px;overflow:auto;margin:10px 0;line-height:1.55}.dvv_md pre code{background:none;border:none;padding:0;display:block;white-space:pre;font-size:13px}.dvv_md blockquote{margin:10px 0;padding:8px 14px;border-left:3px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-base);border-radius:0 8px 8px 0}.dvv_md blockquote p{margin:4px 0}.dvv_md table{border-collapse:collapse;margin:10px 0;width:100%;font:var(--dsw-font-s-13)}.dvv_md th,.dvv_md td{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));padding:7px 10px;text-align:left;vertical-align:top;line-height:1.6}.dvv_md th{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));font-weight:600;white-space:nowrap}.dvv_md th:first-child,.dvv_md td:first-child{width:18%;min-width:96px;max-width:220px;word-break:break-word}.dvv_md hr{border:none;border-top:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));margin:14px 0}.dvv_tabs{display:flex;gap:4px;flex:0 0 auto;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))}.dvv_tab{border:1px solid transparent;border-bottom:none;border-radius:8px 8px 0 0;padding:7px 16px;font:var(--dsw-font-s-14);cursor:pointer;background:transparent;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));transition:color var(--ds-transition-duration-fast) var(--ds-ease-in-out),background var(--ds-transition-duration-fast) var(--ds-ease-in-out)}.dvv_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.dvv_tabActive{border-color:var(--dsw-alias-border-l2,var(--dsw-alias-border));background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);font-weight:600}.dvv_tabBody{flex:1;min-height:0;overflow:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-top:none;border-radius:0 0 8px 8px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))}";
|
||
const key = "dsh-plugin-douyin-video-detail/VideoDetail.module.css";
|
||
if (!document.querySelector("style[data-plugin-css=" + JSON.stringify(key) + "]")) {
|
||
const el = document.createElement("style");
|
||
el.dataset.plugin = "dsh-plugin-douyin-video-detail";
|
||
el.dataset.pluginCss = key;
|
||
el.textContent = css;
|
||
document.head.appendChild(el);
|
||
}
|
||
const cssMap = {
|
||
"page": "dvv_page",
|
||
"body": "dvv_body",
|
||
"toolbar": "dvv_toolbar",
|
||
"btn": "dvv_btn",
|
||
"title": "dvv_title",
|
||
"link": "dvv_link",
|
||
"infoCard": "dvv_infoCard",
|
||
"infoGrid": "dvv_infoGrid",
|
||
"infoItem": "dvv_infoItem",
|
||
"infoItemWide": "dvv_infoItemWide",
|
||
"infoLabel": "dvv_infoLabel",
|
||
"infoValue": "dvv_infoValue",
|
||
"analysisHead": "dvv_analysisHead",
|
||
"analysisWrap": "dvv_analysisWrap",
|
||
"tabs": "dvv_tabs",
|
||
"tab": "dvv_tab",
|
||
"tabActive": "dvv_tabActive",
|
||
"tabBody": "dvv_tabBody",
|
||
"state": "dvv_state",
|
||
"loading": "dvv_loading",
|
||
"error": "dvv_error",
|
||
"empty": "dvv_empty",
|
||
"md": "dvv_md"
|
||
};
|
||
//#endregion
|
||
//#region 极简 markdown 渲染(视频分析内容:无第三方依赖,渲染为 React 元素避免 XSS)
|
||
// 行内语法:**粗体** / *斜体* / `行内代码` / [链接](url)(仅允许 http/https/mailto)
|
||
const INLINE_RE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
|
||
// 文本段:含 \n 时拆为文本 + <br/>(段落内换行在 HTML 中默认折叠为空格,需显式换行)
|
||
function textWithBreaks(seg, keyBase) {
|
||
if (!seg.includes("\n")) return [seg];
|
||
const parts = seg.split("\n");
|
||
const out = [];
|
||
parts.forEach((p, i) => {
|
||
if (p) out.push(p);
|
||
if (i < parts.length - 1) out.push(react.createElement("br", { key: keyBase + "-br" + i }));
|
||
});
|
||
return out;
|
||
}
|
||
function parseInline(text, keyBase) {
|
||
if (!text) return [text];
|
||
const nodes = [];
|
||
let last = 0, k = 0;
|
||
for (const m of text.matchAll(INLINE_RE)) {
|
||
if (m.index > last) nodes.push(...textWithBreaks(text.slice(last, m.index), keyBase + "-t" + k));
|
||
const tok = m[0];
|
||
if (tok.startsWith("**") && tok.endsWith("**") && tok.length > 4) {
|
||
nodes.push(react.createElement("strong", { key: keyBase + "-b" + k }, tok.slice(2, -2)));
|
||
} else if (tok.startsWith("*") && tok.endsWith("*") && tok.length > 2) {
|
||
nodes.push(react.createElement("em", { key: keyBase + "-i" + k }, tok.slice(1, -1)));
|
||
} else if (tok.startsWith("`") && tok.endsWith("`") && tok.length > 2) {
|
||
nodes.push(react.createElement("code", { key: keyBase + "-c" + k }, tok.slice(1, -1)));
|
||
} else if (tok.startsWith("[")) {
|
||
const mm = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||
const href = mm && /^(https?:|mailto:)/i.test(mm[2]) ? mm[2] : null;
|
||
nodes.push(href
|
||
? react.createElement("a", { key: keyBase + "-a" + k, href: href, target: "_blank", rel: "noopener noreferrer" }, mm[1])
|
||
: (mm ? mm[1] : tok));
|
||
} else {
|
||
nodes.push(tok);
|
||
}
|
||
last = m.index + tok.length;
|
||
k++;
|
||
}
|
||
if (last < text.length) nodes.push(...textWithBreaks(text.slice(last), keyBase + "-t" + k));
|
||
return nodes;
|
||
}
|
||
// 表格块:rows 为已去掉分隔行的字符串数组
|
||
function mdTable(rows, keyBase) {
|
||
const split = (r) => r.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
||
const head = split(rows[0]);
|
||
const body = rows.slice(1).map(split);
|
||
return react.createElement("table", { key: keyBase },
|
||
react.createElement("thead", null,
|
||
react.createElement("tr", null, head.map((h, i) => react.createElement("th", { key: "h" + i }, parseInline(h, keyBase + "-h" + i))))),
|
||
react.createElement("tbody", null,
|
||
body.map((row, ri) => react.createElement("tr", { key: "r" + ri },
|
||
row.map((c, ci) => react.createElement("td", { key: "c" + ci }, parseInline(c, keyBase + "-c" + ri + "-" + ci)))))));
|
||
}
|
||
// 块级解析:标题/代码块/引用/表格/列表/分隔线/段落
|
||
function mdToReact(md) {
|
||
if (typeof md !== "string" || !md.trim()) return null;
|
||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||
const out = [];
|
||
let i = 0, k = 0;
|
||
while (i < lines.length) {
|
||
const t = lines[i].trim();
|
||
// 围栏代码块
|
||
const fence = t.match(/^```(\S*)\s*$/);
|
||
if (fence) {
|
||
const buf = [];
|
||
i++;
|
||
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) { buf.push(lines[i]); i++; }
|
||
i++;
|
||
out.push(react.createElement("pre", { key: "md" + k++ },
|
||
react.createElement("code", null, buf.join("\n"))));
|
||
continue;
|
||
}
|
||
// 标题
|
||
const h = t.match(/^(#{1,4})\s+(.*)$/);
|
||
if (h) {
|
||
out.push(react.createElement("h" + h[1].length, { key: "md" + k++ }, parseInline(h[2], "h" + k)));
|
||
i++;
|
||
continue;
|
||
}
|
||
// 分隔线
|
||
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) {
|
||
out.push(react.createElement("hr", { key: "md" + k++ }));
|
||
i++;
|
||
continue;
|
||
}
|
||
// 引用
|
||
if (t.startsWith(">")) {
|
||
const buf = [];
|
||
while (i < lines.length && lines[i].trim().startsWith(">")) { buf.push(lines[i].trim().replace(/^>\s?/, "")); i++; }
|
||
out.push(react.createElement("blockquote", { key: "md" + k++ }, mdToReact(buf.join("\n"))));
|
||
continue;
|
||
}
|
||
// 表格:当前行含 | 且下一行为 |---| 分隔行
|
||
if (t.includes("|") && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes("-")) {
|
||
const sep = lines[i + 1].replace(/\s/g, "");
|
||
if (/^\|?(:?-+:?\|)+:?-+:?\|?$/.test(sep)) {
|
||
const buf = [t];
|
||
i += 2;
|
||
while (i < lines.length && lines[i].trim().includes("|")) { buf.push(lines[i].trim()); i++; }
|
||
out.push(mdTable(buf, "md" + k++));
|
||
continue;
|
||
}
|
||
}
|
||
// 无序列表
|
||
const ul = t.match(/^[-*+]\s+(.*)$/);
|
||
if (ul) {
|
||
const items = [];
|
||
while (i < lines.length) {
|
||
const mt = lines[i].trim().match(/^[-*+]\s+(.*)$/);
|
||
if (mt) { items.push(mt[1]); i++; continue; }
|
||
break;
|
||
}
|
||
out.push(react.createElement("ul", { key: "md" + k++ },
|
||
items.map((it, ii) => react.createElement("li", { key: ii }, parseInline(it, "li" + k + "-" + ii)))));
|
||
continue;
|
||
}
|
||
// 有序列表
|
||
const ol = t.match(/^\d+[.)]\s+(.*)$/);
|
||
if (ol) {
|
||
const items = [];
|
||
while (i < lines.length) {
|
||
const mt = lines[i].trim().match(/^\d+[.)]\s+(.*)$/);
|
||
if (mt) { items.push(mt[1]); i++; continue; }
|
||
break;
|
||
}
|
||
out.push(react.createElement("ol", { key: "md" + k++ },
|
||
items.map((it, ii) => react.createElement("li", { key: ii }, parseInline(it, "li" + k + "-" + ii)))));
|
||
continue;
|
||
}
|
||
// 空行跳过
|
||
if (t === "") { i++; continue; }
|
||
// 段落:合并连续普通行(不跨空行)
|
||
const para = [t];
|
||
i++;
|
||
while (i < lines.length) {
|
||
const nt = lines[i].trim();
|
||
if (nt === "" || /^(#{1,4}\s|```|>|[-*+]\s|\d+[.)]\s|.*\|)/.test(nt) || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(nt)) break;
|
||
para.push(nt);
|
||
i++;
|
||
}
|
||
out.push(react.createElement("p", { key: "md" + k++ }, parseInline(para.join("\n"), "p" + k)));
|
||
}
|
||
return out;
|
||
}
|
||
//#endregion
|
||
//#region 视频详情页
|
||
const fmt = (v) => (v == null || v === "") ? "-" : String(v);
|
||
// 时长格式化:秒 → 「X分Y秒」(不足 1 分钟显示「X秒」)
|
||
const fmtDuration = (v) => {
|
||
const s = Number(v);
|
||
if (!Number.isFinite(s) || s <= 0) return "-";
|
||
const m = Math.floor(s / 60);
|
||
const sec = Math.round(s % 60);
|
||
return m > 0 ? `${m}分${sec}秒` : `${sec}秒`;
|
||
};
|
||
//#region 视频解析任务跟踪(V1.0 同步:解析/刷新 + 断点恢复;localStorage 持久化,页面重建后继续盯任务)
|
||
// 记录:{ ts, force };ts=提交时刻。轮询 /mcn/api/task/status 的 analyze[](videoId→running)。
|
||
// 结束判定:提交后 90s 宽限窗(服务端 60s 防抖)+ 超时上限 35 分钟。
|
||
const PARSE_DEADLINE_MS = 35 * 60 * 1000;
|
||
const PARSE_START_WINDOW_MS = 90 * 1000;
|
||
const taskKeyOf = (id) => "dvv_parse_task_" + id;
|
||
const readTaskRec = (id) => {
|
||
try { const raw = localStorage.getItem(taskKeyOf(id)); if (!raw) return null; const o = JSON.parse(raw); return o && o.ts ? o : null; } catch (e) { return null; }
|
||
};
|
||
const writeTaskRec = (id, force) => {
|
||
try { localStorage.setItem(taskKeyOf(id), JSON.stringify({ ts: Date.now(), force: !!force })); } catch (e) {}
|
||
};
|
||
const clearTaskRec = (id) => {
|
||
try { localStorage.removeItem(taskKeyOf(id)); } catch (e) {}
|
||
};
|
||
//#endregion
|
||
// 视频拆解(analysis.json dict,12-13 维度)→ markdown 文本(每个维度一节,供 mdToReact 渲染)
|
||
const analysisToMarkdown = (obj) => Object.entries(obj || {})
|
||
.map(([k, v]) => `## ${k}\n\n${v}`)
|
||
.join("\n\n---\n\n");
|
||
// 详情页 tab:视频脚本(content)→ 视频拆解(analysis)
|
||
const DETAIL_TABS = [
|
||
{ id: "script", label: "视频脚本" },
|
||
{ id: "deconstruct", label: "视频拆解" },
|
||
];
|
||
function VideoDetailPage({ params }) {
|
||
const videoId = params && params.videoId != null ? Number(params.videoId) : null;
|
||
const awemeId = params && params.awemeId != null ? String(params.awemeId) : null;
|
||
const accountId = params && params.accountId != null ? Number(params.accountId) : null;
|
||
const [video, setVideo] = react.useState(null);
|
||
const [analysis, setAnalysis] = react.useState(null);
|
||
const [parse, setParse] = react.useState(null);
|
||
const [activeTab, setActiveTab] = react.useState("script");
|
||
const [loading, setLoading] = react.useState(videoId != null || awemeId != null);
|
||
const [error, setError] = react.useState(null);
|
||
// V1.0 同步:解析任务状态 idle|submitting|running + 提示条 + 手动重载计数
|
||
const [parseState, setParseState] = react.useState("idle");
|
||
const [info, setInfo] = react.useState(null);
|
||
const [reloadTick, setReloadTick] = react.useState(0);
|
||
const parseVideoId = videoId; // 解析任务只能按库内视频发起(aweme 直链无行不提供解析)
|
||
|
||
react.useEffect(() => {
|
||
if (videoId == null && awemeId == null) return;
|
||
let alive = true;
|
||
setLoading(true);
|
||
setError(null);
|
||
setVideo(null);
|
||
setAnalysis(null);
|
||
setParse(null);
|
||
setActiveTab("script");
|
||
const qs = new URLSearchParams();
|
||
if (awemeId) qs.set("awemeId", awemeId);
|
||
else qs.set("videoId", String(videoId));
|
||
fetch("/mcn/api/video/detail?" + qs.toString())
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||
if (!alive) return;
|
||
setVideo(d.video || null);
|
||
setAnalysis(d.analysis || null);
|
||
setParse(d.parse || null);
|
||
})
|
||
.catch((e) => { if (alive) setError(e.message); })
|
||
.finally(() => { if (alive) setLoading(false); });
|
||
return () => { alive = false; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [videoId, awemeId, reloadTick]);
|
||
|
||
// V1.0 同步:解析任务轮询(5s,断点恢复:localStorage 有记录 → 页面重建后继续盯)。
|
||
// 初始态恢复:挂载后立即检查一次(record 存在且未超时 → running)。
|
||
react.useEffect(() => {
|
||
if (parseVideoId == null) return;
|
||
let finishedOnce = false;
|
||
const check = () => {
|
||
const rec = readTaskRec(parseVideoId);
|
||
fetch("/mcn/api/task/status")
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) return;
|
||
const arr = Array.isArray(d.analyze) ? d.analyze : [];
|
||
const item = arr.find((a) => Number(a.videoId) === parseVideoId);
|
||
const serverRunning = !!(item && item.status === "running");
|
||
if (rec) {
|
||
const age = Date.now() - rec.ts;
|
||
if (age > PARSE_DEADLINE_MS) {
|
||
clearTaskRec(parseVideoId);
|
||
setParseState("idle");
|
||
setInfo("解析任务超过 35 分钟未完成(可能已中断),可重新发起");
|
||
return;
|
||
}
|
||
if (serverRunning || age < PARSE_START_WINDOW_MS) {
|
||
setParseState((prev) => (prev === "submitting" ? "running" : prev === "running" ? "running" : "running"));
|
||
return;
|
||
}
|
||
// 记录存在、服务端空闲、宽限窗已过 → 任务结束
|
||
clearTaskRec(parseVideoId);
|
||
setParseState("idle");
|
||
if (!finishedOnce) {
|
||
finishedOnce = true;
|
||
setInfo("解析任务已结束,正在刷新数据…");
|
||
setTimeout(() => setReloadTick((x) => x + 1), 300);
|
||
}
|
||
} else if (serverRunning) {
|
||
setParseState("running");
|
||
} else if (parseState === "submitting") {
|
||
// 提交后宽限期内服务端尚无条目:保持 waiting,交给 rec 分支处理
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
// 挂载恢复 + 周期轮询
|
||
check();
|
||
const timer = setInterval(check, 5000);
|
||
return () => clearInterval(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [parseVideoId]);
|
||
|
||
// 提示条自动消隐
|
||
react.useEffect(() => {
|
||
if (!info) return;
|
||
const timer = setTimeout(() => setInfo(null), 6000);
|
||
return () => clearTimeout(timer);
|
||
}, [info]);
|
||
|
||
// 提交解析/刷新(V1.0 同步:force=覆盖重解析;服务端 60s 防抖 + 8 并发上限)
|
||
const submitParse = (force) => {
|
||
if (parseVideoId == null) return;
|
||
const tip = force
|
||
? "该视频已有解析结果,「刷新」将重新发起 MCP 解析并覆盖(约 3-5 分钟)。确认继续?"
|
||
: "对该视频发起 AI 解析(MCP 内容解析,约 3-5 分钟,产出「视频脚本」+「视频拆解」)。确认继续?";
|
||
if (!window.confirm(tip)) return;
|
||
setParseState("submitting");
|
||
fetch("/mcn/api/video/analyze", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ videoId: parseVideoId }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (d.ok) {
|
||
writeTaskRec(parseVideoId, force);
|
||
setParseState("running");
|
||
setInfo("已提交解析任务(约 3-5 分钟),完成后自动刷新。可在「MCN任务会话」查看执行过程");
|
||
} else {
|
||
setParseState("idle");
|
||
setInfo(d.error || "提交失败");
|
||
}
|
||
})
|
||
.catch((e) => { setParseState("idle"); setInfo("提交失败: " + e.message); });
|
||
};
|
||
|
||
// 无参数(从工作台直接打开):提示从账号详情进入
|
||
if (videoId == null && awemeId == null) {
|
||
return react.createElement("main", { className: cssMap.page, "aria-label": "视频详情" },
|
||
react.createElement("div", { className: cssMap.body },
|
||
react.createElement("div", { className: cssMap.toolbar },
|
||
react.createElement("button", { type: "button", className: cssMap.btn, title: "返回账号列表", onClick: () => window.__mcnNav?.open("douyin-accounts") }, "←")),
|
||
react.createElement("div", { className: cssMap.empty }, "请从「账号详情」的视频列表点击视频ID进入")));
|
||
}
|
||
|
||
const infoItem = (label, value, wide) => {
|
||
const cls = cssMap.infoItem + (wide ? " " + cssMap.infoItemWide : "");
|
||
return react.createElement("div", { className: cls, key: label },
|
||
react.createElement("div", { className: cssMap.infoLabel }, label),
|
||
react.createElement("div", { className: cssMap.infoValue }, value));
|
||
};
|
||
const backTitle = video ? (video.video_title || "视频详情") : "视频详情";
|
||
// V1.0 同步:解析/刷新按钮(有解析结果=「刷新」强制覆盖;无=「解析」发起)
|
||
const hasParsed = !!(parse && (parse.content || parse.analysis));
|
||
const parseBusy = parseState === "submitting" || parseState === "running";
|
||
let parseBtn = null;
|
||
if (video && parseVideoId != null) {
|
||
parseBtn = react.createElement("button", {
|
||
type: "button",
|
||
className: cssMap.btn,
|
||
disabled: parseBusy,
|
||
title: parseBusy
|
||
? "解析任务执行中(可在 MCN任务会话 查看)"
|
||
: (hasParsed
|
||
? "重新发起 MCP 解析并覆盖现有脚本/拆解(约 3-5 分钟)"
|
||
: "对该视频发起 MCP 内容解析(约 3-5 分钟)"),
|
||
onClick: () => submitParse(hasParsed),
|
||
}, parseBusy ? (parseState === "submitting" ? "提交中…" : "解析中…") : (hasParsed ? "刷新" : "解析"));
|
||
}
|
||
// V1.0 对齐:查看原视频(外链)
|
||
let viewVideoBtn = null;
|
||
if (video && video.video_url) {
|
||
viewVideoBtn = react.createElement("a", { className: cssMap.btn, href: video.video_url, target: "_blank", rel: "noopener noreferrer", title: "在抖音打开原视频", style: { marginLeft: "auto" } }, "查看视频 ↗");
|
||
}
|
||
// 视频信息卡
|
||
let infoCard = null;
|
||
let analysisArea = null;
|
||
if (loading) {
|
||
analysisArea = react.createElement("div", { className: cssMap.loading }, "加载中…");
|
||
} else if (error) {
|
||
analysisArea = react.createElement("div", { className: cssMap.error }, error);
|
||
} else if (video) {
|
||
const titleVal = video.video_url
|
||
? react.createElement("a", {
|
||
className: cssMap.link,
|
||
href: video.video_url,
|
||
target: "_blank",
|
||
rel: "noopener noreferrer",
|
||
title: "在新页面打开视频",
|
||
}, fmt(video.video_title))
|
||
: fmt(video.video_title);
|
||
infoCard = react.createElement("div", { className: cssMap.infoCard },
|
||
react.createElement("div", { className: cssMap.infoGrid },
|
||
infoItem("账号", video.account_id ? react.createElement("a", { className: cssMap.link, style: { cursor: "pointer" }, title: "查看账号详情", onClick: () => { try { window.__mcnNav && window.__mcnNav.open("douyin-account-detail", { accountId: video.account_id, accountName: video.account_name }); } catch (e) {} } }, fmt(video.account_name)) : fmt(video.account_name)),
|
||
infoItem("视频ID", fmt(video.aweme_id)),
|
||
infoItem("时长", fmtDuration(video.duration)),
|
||
infoItem("发布时间", fmt(video.publish_time)),
|
||
infoItem("点赞", fmt(video.like_display || video.like_count)),
|
||
infoItem("评论", fmt(video.comment_count)),
|
||
infoItem("分享", fmt(video.share_count)),
|
||
infoItem("收藏", fmt(video.collect_count)),
|
||
infoItem("标签", fmt(video.tags), true)));
|
||
// 三个 tab 内容:视频脚本(MCP content)→ 视频拆解(MCP analysis)→ 视频分析(拆解分析)
|
||
const scriptMd = parse && parse.content;
|
||
const deconstructMd = parse && parse.analysis ? analysisToMarkdown(parse.analysis) : null;
|
||
const analysisMd = analysis && analysis.content;
|
||
let tabBody = null;
|
||
if (activeTab === "script") {
|
||
tabBody = scriptMd
|
||
? react.createElement("div", { className: cssMap.md }, mdToReact(scriptMd))
|
||
: react.createElement("div", { className: cssMap.state }, "该视频暂无视频脚本(未做原视频解析,可点右上角「解析」发起)");
|
||
} else if (activeTab === "deconstruct") {
|
||
tabBody = deconstructMd
|
||
? react.createElement("div", { className: cssMap.md }, mdToReact(deconstructMd))
|
||
: react.createElement("div", { className: cssMap.state }, "该视频暂无视频拆解(MCP 结构化分析,可点右上角「解析」发起)");
|
||
} else {
|
||
tabBody = analysisMd
|
||
? react.createElement("div", { className: cssMap.md }, mdToReact(analysisMd))
|
||
: react.createElement("div", { className: cssMap.state }, "该视频暂无视频分析(完成视频分析后展示)");
|
||
}
|
||
analysisArea = react.createElement(react.Fragment, null,
|
||
react.createElement("div", { className: cssMap.tabs },
|
||
DETAIL_TABS.map((t) => react.createElement("button", {
|
||
key: t.id,
|
||
type: "button",
|
||
className: cssMap.tab + (activeTab === t.id ? " " + cssMap.tabActive : ""),
|
||
onClick: () => setActiveTab(t.id),
|
||
}, t.label))),
|
||
react.createElement("div", { className: cssMap.tabBody }, tabBody));
|
||
}
|
||
return react.createElement("main", { className: cssMap.page, "aria-label": "视频详情" },
|
||
react.createElement("div", { className: cssMap.body },
|
||
react.createElement("div", { className: cssMap.toolbar },
|
||
react.createElement("button", {
|
||
type: "button",
|
||
className: cssMap.btn,
|
||
title: "返回账号详情",
|
||
onClick: () => window.__mcnNav?.back(),
|
||
}, "←"),
|
||
react.createElement("span", { className: cssMap.title }, backTitle),
|
||
viewVideoBtn,
|
||
parseBtn),
|
||
info ? react.createElement("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#8a8f98)" } }, info) : null,
|
||
infoCard,
|
||
analysisArea));
|
||
}
|
||
//#endregion
|
||
//#region plugin entry:注册到 MCN 工作台入口注册表(hidden:不显示在工作台网格,仅程序化导航打开)
|
||
// 统一线性 SVG 图标(Lucide 风格)
|
||
const IconVideo = ({ size = 16 }) => react.createElement("svg", {
|
||
viewBox: "0 0 24 24",
|
||
width: size,
|
||
height: size,
|
||
fill: "none",
|
||
stroke: "currentColor",
|
||
strokeWidth: "2",
|
||
strokeLinecap: "round",
|
||
strokeLinejoin: "round",
|
||
"aria-hidden": "true",
|
||
}, [react.createElement("path", { key: "p1", d: "m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" }), react.createElement("rect", { key: "p2", x: "2", y: "6", width: "14", height: "12", rx: "2" })]);
|
||
const inject = [];
|
||
function apply() {
|
||
window.__mcnEntries = window.__mcnEntries || [];
|
||
// 幂等:避免插件热重载重复注册
|
||
if (!window.__mcnEntries.some((e) => e.id === "douyin-video-detail")) {
|
||
window.__mcnEntries.push({
|
||
id: "douyin-video-detail",
|
||
icon: IconVideo,
|
||
title: "视频详情",
|
||
desc: "视频信息与拆解分析(从账号详情视频列表进入)",
|
||
hidden: true,
|
||
component: VideoDetailPage,
|
||
});
|
||
}
|
||
}
|
||
//#endregion
|
||
exports.apply = apply;
|
||
exports.inject = inject;
|
||
return module.exports;
|
||
}
|
||
});
|