本提交为 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 本地已不存在,本提交记为删除
878 lines
52 KiB
JavaScript
878 lines
52 KiB
JavaScript
window.__ModuleLoader__.load({
|
||
id: "dsh-plugin-douyin-account-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 = ".dvd_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}.dvd_body{display:flex;flex-direction:column;height:100%;min-height:0;padding:16px;box-sizing:border-box;gap:12px}.dvd_toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.dvd_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)}.dvd_btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dvd_btn:disabled{opacity:.55;cursor:not-allowed}.dvd_btnSm{padding:3px 10px;font-size:12px;border-radius:6px;white-space:nowrap;min-width:70px}.dvd_btnPrimary{background:var(--dsw-alias-brand-primary,#4d6bfe);color:#fff;border-color:transparent}.dvd_btnPrimary:hover{background:var(--dsw-alias-brand-primary-hover,#3a55d6)}.dvd_mask{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:flex-start;justify-content:center;padding-top:26vh;z-index:100}.dvd_modal{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border-radius:12px;padding:20px;min-width:320px;max-width:480px;width:min(480px,92vw);box-shadow:0 8px 30px rgba(0,0,0,.2)}.dvd_modalTitle{font-size:16px;font-weight:600;margin-bottom:12px;color:var(--dsw-alias-label-primary)}.dvd_modalText{font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary);margin-bottom:16px;line-height:1.7;word-break:break-all;display:flex;flex-direction:column;gap:6px}.dvd_modalBtns{display:flex;justify-content:flex-end;gap:8px}.dvd_title{font:var(--dsw-font-l-16);font-weight:600;color:var(--dsw-alias-label-primary)}.dvd_link{color:var(--dsw-alias-accent-fg);cursor:pointer;text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvd_link:hover{text-decoration:underline}.dvd_badge{display:inline-block;padding:2px 10px;border-radius:999px;font:var(--dsw-font-s-12);line-height:20px}.dvd_badgeSigned{background:var(--dsw-alias-accent-bg-subtle,var(--dsw-alias-accent-bg-soft));color:var(--dsw-alias-accent-fg)}.dvd_badgeExternal{background:var(--dsw-alias-ok-bg-subtle,var(--dsw-alias-ok-bg-soft));color:var(--dsw-alias-ok-fg)}.dvd_tabsCard{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));overflow:hidden}.dvd_infoGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px 18px;flex:0 0 auto;margin-bottom:12px}.dvd_infoItem{display:flex;flex-direction:column;gap:4px;min-width:0}.dvd_infoItemWide{grid-column:1/-1}.dvd_infoLabel{font:var(--dsw-font-s-12);color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_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;max-height:140px;overflow-y:auto}.dvd_tableWrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px}.dvd_table{width:100%;border-collapse:collapse;font:var(--dsw-font-s-14);min-width:1180px}.dvd_table th{position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));text-align:left;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));font-weight:600;white-space:nowrap;user-select:none}.dvd_table td{padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));vertical-align:middle;white-space:nowrap;max-width:260px;overflow:hidden;text-overflow:ellipsis;line-height:1.5}.dvd_table td.dvd_cellTitle{max-width:300px}.dvd_pager{display:flex;align-items:center;gap:12px;flex:0 0 auto;margin-top:10px}.dvd_pagerInfo{font:var(--dsw-font-s-13);color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_error{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-danger-fg,var(--dsw-alias-error-fg))}.dvd_empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_tabs{display:flex;align-items:stretch;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));padding:0 8px;gap:2px}.dvd_tab{background:none;border:none;padding:10px 16px;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--ds-transition-duration-fast) var(--ds-ease-in-out)}.dvd_tab:hover{color:var(--dsw-alias-label-primary)}.dvd_tabActive{color:var(--dsw-alias-accent-fg);font-weight:600;border-bottom-color:var(--dsw-alias-accent-fg)}.dvd_tabPanel{flex:1;min-height:0;display:flex;flex-direction:column;padding:14px 16px;box-sizing:border-box;overflow:hidden}.dvd_personaPanel{overflow:auto;padding-right:6px}.dvd_tabState{padding:28px 16px;text-align:center;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));font:var(--dsw-font-s-14)}.dvd_md{font:var(--dsw-font-s-14);line-height:1.75;color:var(--dsw-alias-label-primary);word-break:break-word}.dvd_md h1,.dvd_md h2,.dvd_md h3,.dvd_md h4{margin:18px 0 8px;font-weight:600;line-height:1.4}.dvd_md h1{font-size:20px;margin-top:26px}.dvd_md h2{font-size:17px;margin-top:24px}.dvd_md h3{font-size:15px;margin-top:20px}.dvd_md h4{font-size:14px;margin-top:18px}.dvd_md p{margin:8px 0}.dvd_md ul,.dvd_md ol{margin:8px 0;padding-left:22px}.dvd_md li{margin:2px 0}.dvd_md a{color:var(--dsw-alias-accent-fg);text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvd_md a:hover{text-decoration:underline}.dvd_md strong{font-weight:600}.dvd_md em{font-style:italic}.dvd_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}.dvd_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}.dvd_md pre code{background:none;border:none;padding:0;display:block;white-space:pre;font-size:13px}.dvd_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}.dvd_md blockquote p{margin:4px 0}.dvd_md table{border-collapse:collapse;margin:10px 0;width:100%;font:var(--dsw-font-s-13)}.dvd_md th,.dvd_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}.dvd_md th{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));font-weight:600;white-space:nowrap}.dvd_md th:first-child,.dvd_md td:first-child{width:18%;min-width:96px;max-width:220px;word-break:break-word}.dvd_md hr{border:none;border-top:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));margin:14px 0}";
|
||
const key = "dsh-plugin-douyin-account-detail/Detail.module.css";
|
||
if (!document.querySelector("style[data-plugin-css=" + JSON.stringify(key) + "]")) {
|
||
const el = document.createElement("style");
|
||
el.dataset.plugin = "dsh-plugin-douyin-account-detail";
|
||
el.dataset.pluginCss = key;
|
||
el.textContent = css;
|
||
if (!document.querySelector('style[data-plugin-css="dvd-align"]')) {
|
||
const el2 = document.createElement("style");
|
||
el2.dataset.pluginCss = "dvd-align";
|
||
el2.textContent = ".dvd_table thead th:last-child{text-align:center!important}.dvd_table tbody td:last-child{text-align:center!important}";
|
||
document.head.appendChild(el2);
|
||
}
|
||
document.head.appendChild(el);
|
||
}
|
||
const cssMap = {
|
||
"page": "dvd_page",
|
||
"body": "dvd_body",
|
||
"toolbar": "dvd_toolbar",
|
||
"btn": "dvd_btn",
|
||
"btnSm": "dvd_btnSm",
|
||
"btnPrimary": "dvd_btnPrimary",
|
||
"mask": "dvd_mask",
|
||
"modal": "dvd_modal",
|
||
"modalTitle": "dvd_modalTitle",
|
||
"modalText": "dvd_modalText",
|
||
"modalBtns": "dvd_modalBtns",
|
||
"title": "dvd_title",
|
||
"link": "dvd_link",
|
||
"badge": "dvd_badge",
|
||
"badgeSigned": "dvd_badgeSigned",
|
||
"badgeExternal": "dvd_badgeExternal",
|
||
"infoCard": "dvd_tabsCard",
|
||
"infoGrid": "dvd_infoGrid",
|
||
"infoItem": "dvd_infoItem",
|
||
"infoItemWide": "dvd_infoItemWide",
|
||
"infoLabel": "dvd_infoLabel",
|
||
"infoValue": "dvd_infoValue",
|
||
"tabs": "dvd_tabs",
|
||
"tab": "dvd_tab",
|
||
"tabActive": "dvd_tabActive",
|
||
"tabPanel": "dvd_tabPanel",
|
||
"personaPanel": "dvd_personaPanel",
|
||
"tabState": "dvd_tabState",
|
||
"md": "dvd_md",
|
||
"tableWrap": "dvd_tableWrap",
|
||
"table": "dvd_table",
|
||
"cellTitle": "dvd_cellTitle",
|
||
"pager": "dvd_pager",
|
||
"pagerInfo": "dvd_pagerInfo",
|
||
"loading": "dvd_loading",
|
||
"error": "dvd_error",
|
||
"empty": "dvd_empty"
|
||
};
|
||
//#endregion
|
||
//#region 极简 markdown 渲染(账号设定 tab:无第三方依赖,渲染为 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);
|
||
const firstColStyle = { width: "88px", maxWidth: "88px", minWidth: 0, wordBreak: "break-word" };
|
||
return react.createElement("table", { key: keyBase, style: { width: "100%" } },
|
||
react.createElement("thead", null,
|
||
react.createElement("tr", null, head.map((h, i) => react.createElement("th", { key: "h" + i, style: i === 0 ? firstColStyle : undefined }, 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, style: ci === 0 ? firstColStyle : undefined }, 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 账号详情页
|
||
// 统一线性 SVG 图标(Lucide 风格:24 viewBox、stroke currentColor、圆角端点)
|
||
const SvgBase = ({ d, 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",
|
||
style: { verticalAlign: "-2px" },
|
||
}, d);
|
||
const IconDownload = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("path", { key: "p1", d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), react.createElement("polyline", { key: "p2", points: "7 10 12 15 17 10" }), react.createElement("line", { key: "p3", x1: "12", y1: "15", x2: "12", y2: "3" })] });
|
||
const IconChart = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("path", { key: "p1", d: "M3 3v18h18" }), react.createElement("path", { key: "p2", d: "M18 17V9" }), react.createElement("path", { key: "p3", d: "M13 17V5" }), react.createElement("path", { key: "p4", d: "M8 17v-3" })] });
|
||
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}秒`;
|
||
};
|
||
// 视频表格列
|
||
const COLUMNS = [
|
||
["序号", (r, i) => String(i)],
|
||
["视频ID", (r) => r.aweme_id],
|
||
["视频标题", (r) => r.video_title],
|
||
["点赞", (r) => r.like_display || fmt(r.like_count)],
|
||
["评论", (r) => fmt(r.comment_count)],
|
||
["分享", (r) => fmt(r.share_count)],
|
||
["收藏", (r) => fmt(r.collect_count)],
|
||
["播放", (r) => (r.play_count == null || Number(r.play_count) === 0) ? "-" : fmt(r.play_count)],
|
||
["时长", (r) => fmtDuration(r.duration)],
|
||
["发布时间", (r) => fmt(r.publish_time)],
|
||
["标签", (r) => fmt(r.tags)]
|
||
];
|
||
//#region V1.0 同步:视频互动趋势(lieflat-charts 风格内联 SVG 双序列折线;不引第三方图表库)
|
||
// 数据 = 该账号全部视频(分页循环拉取,独立于列表分页);窗口 = 近半年(按发布时间)
|
||
const TREND_DAYS = 180;
|
||
const TREND_COLOR_LIKE = "#e5484d";
|
||
const TREND_COLOR_COMMENT = "#2f6fed";
|
||
// 数值混合解析:数字原样;"7.8万"/"500w"/"1.2亿" → 数值
|
||
function numOfMix(v) {
|
||
const s = String(v == null ? "" : v).trim();
|
||
if (!s) return 0;
|
||
const m = s.match(/^([\d.]+)\s*(亿|w|万)?$/);
|
||
if (!m) return Number(s) || 0;
|
||
let n = parseFloat(m[1]) || 0;
|
||
if (m[2] === "亿") n *= 100000000;
|
||
else if (m[2]) n *= 10000;
|
||
return n;
|
||
}
|
||
function fmtWan(v) {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n) || n <= 0) return "0";
|
||
return n >= 10000 ? (n / 10000).toFixed(n >= 1000000 ? 0 : 1) + "万" : String(Math.round(n));
|
||
}
|
||
// videos → 升序趋势点(近半年内有发布时间的视频);不足 2 点返回 [](前端显示空态提示)
|
||
function buildTrendPoints(videos) {
|
||
const cutoff = Date.now() - TREND_DAYS * 86400000;
|
||
const pts = [];
|
||
for (const v of videos || []) {
|
||
const ds = String(v.publish_time || "").slice(0, 10);
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ds)) continue;
|
||
const t = new Date(ds + "T00:00:00");
|
||
if (Number.isNaN(t.getTime()) || t.getTime() < cutoff) continue;
|
||
pts.push({
|
||
d: ds,
|
||
like: numOfMix(v.like_display || v.like_count),
|
||
comment: numOfMix(v.comment_count),
|
||
title: String(v.video_title || "").slice(0, 40) || ds,
|
||
});
|
||
}
|
||
pts.sort((a, b) => (a.d < b.d ? -1 : a.d > b.d ? 1 : 0));
|
||
return pts;
|
||
}
|
||
// 内联 SVG 双序列折线(左轴=点赞 右轴=评论,各自归一化到自身量程)
|
||
function trendSvg(pts) {
|
||
const W = 640, H = 220, PL = 62, PR = 62, PT = 14, PB = 26;
|
||
const n = pts.length;
|
||
if (n < 2) return null;
|
||
const xs = (i) => PL + (W - PL - PR) * i / (n - 1);
|
||
const lmax = Math.max.apply(null, pts.map((p) => p.like));
|
||
const lmin = Math.min.apply(null, pts.map((p) => p.like));
|
||
const cmax = Math.max.apply(null, pts.map((p) => p.comment));
|
||
const cmin = Math.min.apply(null, pts.map((p) => p.comment));
|
||
const span = (lo, hi) => Math.max(hi - lo, 1);
|
||
const yl = (v) => PT + (H - PT - PB) * (1 - (v - lmin) / span(lmin, Math.max(lmax, 1)));
|
||
const yc = (v) => PT + (H - PT - PB) * (1 - (v - cmin) / span(cmin, Math.max(cmax, 1)));
|
||
const grid = [0.25, 0.5, 0.75].map((g) => {
|
||
const y = PT + (H - PT - PB) * g;
|
||
return react.createElement("line", { key: "g" + g, x1: PL, x2: W - PR, y1: y, y2: y,
|
||
style: { stroke: "var(--dsw-alias-border-l2,#e8e8ea)", strokeWidth: 1 } });
|
||
});
|
||
const likePts = pts.map((p, i) => `${xs(i).toFixed(1)},${yl(p.like).toFixed(1)}`).join(" ");
|
||
const cmtPts = pts.map((p, i) => `${xs(i).toFixed(1)},${yc(p.comment).toFixed(1)}`).join(" ");
|
||
const dots = n <= 40
|
||
? pts.map((p, i) => [
|
||
react.createElement("circle", { key: "dl" + i, cx: xs(i), cy: yl(p.like), r: 2.2, fill: TREND_COLOR_LIKE }),
|
||
react.createElement("circle", { key: "dc" + i, cx: xs(i), cy: yc(p.comment), r: 2.2, fill: TREND_COLOR_COMMENT }),
|
||
])
|
||
: [];
|
||
const last = pts[n - 1];
|
||
const labelFill = { fill: "var(--dsw-alias-label-secondary,#8a8f98)", fontSize: 10 };
|
||
return react.createElement("svg", { viewBox: "0 0 " + W + " " + H, style: { width: "100%", height: "auto", display: "block" }, "aria-label": "互动趋势图" },
|
||
grid,
|
||
react.createElement("polyline", { key: "pl", points: likePts, fill: "none",
|
||
style: { stroke: TREND_COLOR_LIKE, strokeWidth: 1.8 } }),
|
||
react.createElement("polyline", { key: "pc", points: cmtPts, fill: "none",
|
||
style: { stroke: TREND_COLOR_COMMENT, strokeWidth: 1.8 } }),
|
||
dots,
|
||
// 左轴:点赞 量程(顶部=最大)
|
||
react.createElement("text", { x: PL - 6, y: PT + 8, textAnchor: "end", style: Object.assign({ fill: TREND_COLOR_LIKE, fontSize: 10 }, {}) }, fmtWan(lmax)),
|
||
react.createElement("text", { x: PL - 6, y: H - PB, textAnchor: "end", style: labelFill }, "0"),
|
||
// 右轴:评论 量程(顶部=最大)
|
||
react.createElement("text", { x: W - PR + 6, y: PT + 8, textAnchor: "start", style: { fill: TREND_COLOR_COMMENT, fontSize: 10 } }, fmtWan(cmax)),
|
||
react.createElement("text", { x: W - PR + 6, y: H - PB, textAnchor: "start", style: labelFill }, "0"),
|
||
// 日期刻度:首/中/尾
|
||
react.createElement("text", { x: PL, y: H - 8, textAnchor: "start", style: labelFill }, pts[0].d.slice(5)),
|
||
react.createElement("text", { x: (PL + W - PR) / 2, y: H - 8, textAnchor: "middle", style: labelFill },
|
||
pts[Math.floor((n - 1) / 2)].d.slice(5)),
|
||
react.createElement("text", { x: W - PR, y: H - 8, textAnchor: "end", style: labelFill }, last.d.slice(5)));
|
||
}
|
||
//#endregion
|
||
function AccountDetailPage({ params }) {
|
||
const accountId = params && params.accountId != null ? Number(params.accountId) : null;
|
||
const [account, setAccount] = react.useState(null);
|
||
const [items, setItems] = react.useState([]);
|
||
const [total, setTotal] = react.useState(0);
|
||
const [page, setPage] = react.useState(1);
|
||
const [pageSize] = react.useState(20);
|
||
const [loading, setLoading] = react.useState(accountId != null);
|
||
const [error, setError] = react.useState(null);
|
||
// 账号信息 / 账号设定 tab 状态(设定内容懒加载,仅首次切换时请求)
|
||
const [activeTab, setActiveTab] = react.useState("info");
|
||
const [personaLoading, setPersonaLoading] = react.useState(false);
|
||
const [personaError, setPersonaError] = react.useState(null);
|
||
const [personaData, setPersonaData] = react.useState(null);
|
||
const [personaLoaded, setPersonaLoaded] = react.useState(false);
|
||
// V1.0 同步:账号数据分析 tab(读 account_analysis 最新 md;空态引导)
|
||
const [analysisData, setAnalysisData] = react.useState(null);
|
||
const [analysisLoading, setAnalysisLoading] = react.useState(false);
|
||
const [analysisError, setAnalysisError] = react.useState(null);
|
||
const [analysisLoaded, setAnalysisLoaded] = react.useState(false);
|
||
const [analysisBusy, setAnalysisBusy] = react.useState(false);
|
||
const loadAnalysisRef = react.useRef(null);
|
||
// V1.0 同步:视频互动趋势(近半年点赞/评论折线,数据=该账号全部视频,与分页无关)
|
||
const [trendPoints, setTrendPoints] = react.useState(null);
|
||
// 对标创作状态(toast 提示 + 进行中的视频行 + 确认弹窗行)
|
||
const [toast, setToast] = react.useState(null);
|
||
const [creating, setCreating] = react.useState(null);
|
||
const [benchmarkRow, setBenchmarkRow] = react.useState(null);
|
||
// 视频解析确认弹窗:待解析视频行
|
||
const [analyzeRow, setAnalyzeRow] = react.useState(null);
|
||
// 账号设定提炼:确认弹窗行 + 提炼中状态(每账号独立会话,多账号可并发)
|
||
const [personaRow, setPersonaRow] = react.useState(null);
|
||
const [personaBusy, setPersonaBusy] = react.useState(false);
|
||
const accountIdRef = react.useRef(accountId);
|
||
accountIdRef.current = accountId;
|
||
// 当前账号刷新任务状态跟踪(imports 轮询用)
|
||
const refreshPrevRef = react.useRef(false);
|
||
// 视频解析任务状态(并发):videoId -> true 表示该视频解析中(每视频独立会话,互不阻塞)
|
||
const [analyzing, setAnalyzing] = react.useState({});
|
||
const analyzingRef = react.useRef({});
|
||
analyzingRef.current = analyzing;
|
||
const pageRef = react.useRef(1);
|
||
pageRef.current = page;
|
||
const loadRef = react.useRef(null);
|
||
|
||
const load = (p) => {
|
||
if (accountId == null) return;
|
||
setLoading(true);
|
||
fetch("/mcn/api/account/videos?accountId=" + accountId + "&page=" + p + "&pageSize=" + pageSize)
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||
setAccount(d.account || null);
|
||
setItems(d.items || []);
|
||
setTotal(d.total || 0);
|
||
setPage(d.page || 1);
|
||
setError(null);
|
||
})
|
||
.catch((e) => setError(e.message))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
loadRef.current = load;
|
||
// V1.0 同步:拉取该账号全量视频(分页 pageSize=100,上限 6 页)→ 计算趋势点(近半年,独立于列表分页)
|
||
const loadTrend = () => {
|
||
if (accountId == null) return;
|
||
setTrendPoints(null);
|
||
const acc = [];
|
||
const fetchPage = (page) =>
|
||
fetch("/mcn/api/account/videos?accountId=" + accountId + "&page=" + page + "&pageSize=100")
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||
acc.push.apply(acc, d.items || []);
|
||
if (acc.length < (d.total || 0) && page < 6) return fetchPage(page + 1);
|
||
setTrendPoints(buildTrendPoints(acc));
|
||
})
|
||
.catch(() => setTrendPoints([]));
|
||
fetchPage(1);
|
||
};
|
||
// 轮询视频解析任务(5s):某视频从 running → 非 running 即解析完成,自动刷新列表(并发多任务各自独立,完成后各自刷新)
|
||
react.useEffect(() => {
|
||
const timer = setInterval(() => {
|
||
fetch("/mcn/api/task/status")
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok || !Array.isArray(d.analyze)) return;
|
||
const running = {};
|
||
d.analyze.forEach((a) => { if (a.status === "running") running[a.videoId] = true; });
|
||
const prev = analyzingRef.current;
|
||
const next = {};
|
||
let doneAny = false;
|
||
Object.keys(prev).forEach((id) => {
|
||
if (running[id]) next[id] = true;
|
||
else doneAny = true; // 之前解析中 → 现在空闲:该视频解析完成
|
||
});
|
||
Object.keys(running).forEach((id) => { if (!prev[id]) next[id] = true; });
|
||
setAnalyzing(next);
|
||
if (doneAny) {
|
||
const p = pageRef.current;
|
||
setTimeout(() => loadRef.current(p), 300);
|
||
}
|
||
// persona:当前账号提炼状态(running→idle 即完成,刷新账号设定数据)
|
||
const plist = Array.isArray(d.persona) ? d.persona : [];
|
||
const curId = accountIdRef.current;
|
||
const pCur = plist.find((p) => p.accountId === curId);
|
||
const pRunning = !!(pCur && pCur.status === "running");
|
||
setPersonaBusy((prev) => {
|
||
if (pRunning) return true;
|
||
if (prev && !pRunning) {
|
||
setTimeout(() => {
|
||
fetch("/mcn/api/account/persona?accountId=" + accountIdRef.current)
|
||
.then((r) => r.json())
|
||
.then((dd) => {
|
||
if (dd.ok) { setPersonaData({ content: dd.content, analysisTime: dd.analysisTime }); setPersonaLoaded(true); }
|
||
})
|
||
.catch(() => {});
|
||
}, 300);
|
||
}
|
||
return false;
|
||
});
|
||
// accountAnalysis:账号数据分析任务(running→idle 即完成,自动刷新数据分析内容)
|
||
const alist2 = Array.isArray(d.accountAnalysis) ? d.accountAnalysis : [];
|
||
const aCur = alist2.find((t) => t.accountId === accountIdRef.current);
|
||
const aRunning = !!(aCur && aCur.status === "running");
|
||
setAnalysisBusy((prev) => {
|
||
if (aRunning) return true;
|
||
if (prev && !aRunning) {
|
||
setTimeout(() => { setAnalysisLoaded(false); loadAnalysisRef.current(); }, 300);
|
||
}
|
||
return false;
|
||
});
|
||
// imports:当前账号刷新任务(refresh:{accountId})running→idle 即完成,自动刷新账号信息与视频列表
|
||
const ilist = Array.isArray(d.imports) ? d.imports : [];
|
||
const curId2 = accountIdRef.current;
|
||
const refTask = ilist.find((t) => t.id === "refresh:" + curId2);
|
||
const refRunning = !!(refTask && refTask.status === "running");
|
||
if (refreshPrevRef.current && !refRunning) {
|
||
const p = pageRef.current;
|
||
setTimeout(() => loadRef.current(p), 300);
|
||
}
|
||
refreshPrevRef.current = refRunning;
|
||
})
|
||
.catch(() => {});
|
||
}, 5000);
|
||
return () => clearInterval(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
react.useEffect(() => {
|
||
if (!toast) return;
|
||
const timer = setTimeout(() => setToast(null), 4000);
|
||
return () => clearTimeout(timer);
|
||
}, [toast]);
|
||
react.useEffect(() => {
|
||
// 切换账号时重置 tab 状态,避免残留上一账号的内容
|
||
setActiveTab("info");
|
||
setPersonaLoaded(false);
|
||
setPersonaData(null);
|
||
setPersonaError(null);
|
||
setToast(null);
|
||
setCreating(null);
|
||
setBenchmarkRow(null);
|
||
setAnalyzeRow(null);
|
||
setPersonaRow(null);
|
||
setPersonaBusy(false);
|
||
// V1.0 同步:数据分析 tab 状态随账号重置;趋势数据重新拉取
|
||
setAnalysisLoaded(false);
|
||
setAnalysisData(null);
|
||
setAnalysisError(null);
|
||
setTrendPoints(null);
|
||
loadTrend();
|
||
loadPersona();
|
||
load(1);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [accountId]);
|
||
|
||
// 无参数(从工作台直接打开):提示从账号列表进入
|
||
if (accountId == 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?.back() }, "←")),
|
||
react.createElement("div", { className: cssMap.empty }, "请从「热门账号」列表点击账号的抖音号进入详情")));
|
||
}
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||
// 加载账号设定信息(读取 account_persona 最新一条 markdown 内容)
|
||
const loadPersona = () => {
|
||
setPersonaLoading(true);
|
||
setPersonaError(null);
|
||
fetch("/mcn/api/account/persona?accountId=" + accountId)
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||
setPersonaData({ content: d.content, analysisTime: d.analysisTime });
|
||
setPersonaLoaded(true);
|
||
})
|
||
.catch((e) => setPersonaError(e.message))
|
||
.finally(() => setPersonaLoading(false));
|
||
};
|
||
// 切换 tab;账号设定内容懒加载(仅首次);数据分析懒加载(仅首次)
|
||
const switchTab = (tab) => {
|
||
setActiveTab(tab);
|
||
if (tab === "info" && !personaLoaded) loadPersona();
|
||
if (tab === "analysis" && !analysisLoaded) loadAnalysis();
|
||
};
|
||
// 加载账号数据分析(读取 account_analysis 最新一条 markdown 内容,V1.0 同步)
|
||
const loadAnalysis = () => {
|
||
setAnalysisLoading(true);
|
||
setAnalysisError(null);
|
||
fetch("/mcn/api/account/analysis?accountId=" + accountId)
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||
setAnalysisData({ content: d.content, analysisTime: d.analysisTime });
|
||
setAnalysisLoaded(true);
|
||
})
|
||
.catch((e) => setAnalysisError(e.message))
|
||
.finally(() => setAnalysisLoading(false));
|
||
};
|
||
loadAnalysisRef.current = loadAnalysis;
|
||
const tabBtn = (label, value) => react.createElement("button", {
|
||
type: "button",
|
||
role: "tab",
|
||
"aria-selected": activeTab === value,
|
||
className: cssMap.tab + (activeTab === value ? " " + cssMap.tabActive : ""),
|
||
onClick: () => switchTab(value),
|
||
}, label);
|
||
// 改写脚本:点击按钮 → 确认弹窗 → 账号设定 + 视频选题(只要选题)→ 创作全新故事脚本
|
||
// 复用固定会话 dsh_rewrite(无则新建),单任务串行:运行中重复点击会被宿主 409 拒绝
|
||
const REWRITE_SESSION = "dsh_rewrite";
|
||
const openBenchmark = (row) => setBenchmarkRow(row);
|
||
const confirmBenchmark = () => {
|
||
const row = benchmarkRow;
|
||
if (!row) return;
|
||
setBenchmarkRow(null);
|
||
setCreating(row.id);
|
||
// 账号设定:优先用已加载的缓存,否则按需拉取
|
||
const personaP = personaData && personaData.content
|
||
? Promise.resolve(personaData.content)
|
||
: fetch("/mcn/api/account/persona?accountId=" + accountId)
|
||
.then((r) => r.json())
|
||
.then((d) => (d.ok ? (d.content || "") : ""));
|
||
personaP
|
||
.then((persona) => {
|
||
const lines = [
|
||
`请为达人「${account.account_name}」改写一条短视频故事脚本。`,
|
||
``,
|
||
`【账号信息】`,
|
||
`赛道:${account.track || "-"}`,
|
||
`账号定位:${account.content || "-"}`,
|
||
``,
|
||
`【账号设定】`,
|
||
persona || "(该账号暂无账号设定文档)",
|
||
``,
|
||
`【视频选题】`,
|
||
row.topic
|
||
? row.topic
|
||
: `标题:${row.video_title || "-"}\n视频ID:${row.aweme_id || "-"}`,
|
||
``,
|
||
`请基于账号设定与上述视频选题,创作一个全新的故事脚本:`,
|
||
`- 新故事、新情节,不要复刻原视频;`,
|
||
`- 输出完整故事脚本(含标题、故事梗概、口播文案/台词);`,
|
||
`- 不要输出分镜脚本,无需对标分析。`,
|
||
].join("\n");
|
||
return fetch("/mcn/api/creative", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ prompt: lines, sessionId: REWRITE_SESSION }),
|
||
}).then((r) => r.json());
|
||
})
|
||
.then((d) => {
|
||
if (!d.ok) throw new Error(d.error || "触发失败");
|
||
setToast(d.message || "已开始创作,请切换到对应会话查看");
|
||
})
|
||
.catch((e) => setToast("创作触发失败: " + e.message))
|
||
.finally(() => setCreating(null));
|
||
};
|
||
// 解析视频:确认弹窗 → 每视频独立 AI 会话(mcn-dou-analysis 功能五)MCP 解析并保存/写库;多视频可并发提交
|
||
const submitAnalyze = () => {
|
||
const row = analyzeRow;
|
||
if (!row) return;
|
||
setAnalyzeRow(null);
|
||
// 乐观标记:按钮立即变「解析中…」,轮询会从 task/status 同步真实状态
|
||
setAnalyzing((prev) => { const n = Object.assign({}, prev); n[row.id] = true; return n; });
|
||
fetch("/mcn/api/video/analyze", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ videoId: row.id }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) {
|
||
setAnalyzing((prev) => { const n = Object.assign({}, prev); delete n[row.id]; return n; });
|
||
throw new Error(d.error || "提交失败");
|
||
}
|
||
setToast(d.message || "已提交解析,AI 正在解析(约 3-5 分钟)");
|
||
})
|
||
.catch((e) => setToast("解析提交失败: " + e.message));
|
||
};
|
||
// 提炼账号设定:确认弹窗 → 每账号独立 AI 会话(mcn-dou-analysis 功能三)筛选TOP4→MCP解析→生成人设卡并写库;多账号可并发
|
||
const submitPersonaExtract = () => {
|
||
const row = personaRow;
|
||
if (!row) return;
|
||
setPersonaRow(null);
|
||
setPersonaBusy(true);
|
||
fetch("/mcn/api/account/persona/extract", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ accountId: row.id }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) { setPersonaBusy(false); throw new Error(d.error || "提交失败"); }
|
||
setToast(d.message || "已提交提炼账号设定,AI 正在执行(约 10-20 分钟)");
|
||
})
|
||
.catch((e) => setToast("提炼提交失败: " + e.message));
|
||
};
|
||
// 发起账号数据分析(V1.0 同步:mcn-dou-analysis 功能四;每账号独立会话,运行中禁重复发起)
|
||
const submitAccountAnalysis = () => {
|
||
if (accountId == null) return;
|
||
if (!window.confirm("对该账号发起账号数据分析(功能四:近 4 个月最好/最差视频逐条拆解 → 8 章汇总报告,约 10-20 分钟)?")) return;
|
||
setAnalysisBusy(true);
|
||
fetch("/mcn/api/account/analysis/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ accountId: accountId }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) { setAnalysisBusy(false); throw new Error(d.error || "提交失败"); }
|
||
setToast(d.message || "已提交账号数据分析,AI 正在执行(约 10-20 分钟)");
|
||
})
|
||
.catch((e) => { setAnalysisBusy(false); setToast("数据分析提交失败: " + e.message); });
|
||
};
|
||
// 账号信息卡
|
||
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));
|
||
};
|
||
let infoCard = null;
|
||
let tableArea = null;
|
||
if (loading) {
|
||
tableArea = react.createElement("div", { className: cssMap.loading }, "加载中…");
|
||
} else if (error) {
|
||
tableArea = react.createElement("div", { className: cssMap.error }, error);
|
||
} else {
|
||
// 视频表格列(追加操作列:已解析(原始解析或拆解分析)→ 改写脚本;均无 → 下载图标占位;拆解详情经视频ID进入)
|
||
const columns = COLUMNS.concat([[
|
||
"操作",
|
||
(row) => (row.has_source || row.has_analysis)
|
||
? react.createElement("button", {
|
||
type: "button",
|
||
className: cssMap.btn + " " + cssMap.btnSm,
|
||
disabled: creating === row.id,
|
||
title: "基于账号设定与视频选题,改写一条全新的故事脚本",
|
||
onClick: () => openBenchmark(row),
|
||
}, creating === row.id ? "创作中…" : "改写脚本")
|
||
: react.createElement("button", {
|
||
type: "button",
|
||
className: cssMap.btn + " " + cssMap.btnSm,
|
||
disabled: !!analyzing[row.id],
|
||
title: analyzing[row.id] ? "该视频正在解析中(约 3-5 分钟),可同时提交其他视频解析" : "解析本视频(MCP 解析,保存到本地并写入数据库)",
|
||
onClick: () => setAnalyzeRow(row),
|
||
}, analyzing[row.id] ? "解析中…" : react.createElement(IconDownload, {})),
|
||
]]);
|
||
// 视频表格(账号信息 tab 面板内:表格区滚动,分页固定在面板底部)
|
||
const videoTable = react.createElement("div", { className: cssMap.tableWrap },
|
||
items.length === 0
|
||
? react.createElement("div", { className: cssMap.empty }, "暂无视频数据")
|
||
: react.createElement("table", { className: cssMap.table },
|
||
react.createElement("thead", null,
|
||
react.createElement("tr", null,
|
||
columns.map((c) => react.createElement("th", { key: c[0] }, c[0])))),
|
||
react.createElement("tbody", null,
|
||
items.map((row, idx) => react.createElement("tr", { key: row.id },
|
||
columns.map((c) => {
|
||
const val = c[1](row, (page - 1) * pageSize + idx + 1);
|
||
const cellCls = c[0] === "视频标题" ? cssMap.cellTitle : "";
|
||
// 视频标题:有 video_url 时渲染为新页面链接
|
||
let content = val;
|
||
let tip = c[0] === "视频标题" || c[0] === "标签" ? val : undefined;
|
||
if (c[0] === "视频标题" && row.video_url) {
|
||
content = react.createElement("a", {
|
||
className: cssMap.link,
|
||
href: row.video_url,
|
||
target: "_blank",
|
||
rel: "noopener noreferrer",
|
||
title: "在新页面打开视频",
|
||
}, val);
|
||
tip = row.video_url;
|
||
}
|
||
// 视频ID:点击打开视频分析详情页
|
||
if (c[0] === "视频ID") {
|
||
content = react.createElement("span", {
|
||
className: cssMap.link,
|
||
title: "查看视频分析详情",
|
||
onClick: () => window.__mcnNav?.open("douyin-video-detail", { videoId: row.id, awemeId: row.aweme_id, accountId: accountId }),
|
||
}, val);
|
||
tip = "查看视频分析详情";
|
||
}
|
||
return react.createElement("td", { key: c[0], className: cellCls, title: tip }, content);
|
||
}))))));
|
||
const pager = react.createElement("div", { className: cssMap.pager },
|
||
react.createElement("span", { className: cssMap.pagerInfo }, `共 ${total} 条视频`),
|
||
react.createElement("span", { className: cssMap.pagerInfo }, `第 ${page} / ${totalPages} 页`),
|
||
react.createElement("button", { type: "button", className: cssMap.btn, disabled: page <= 1 || loading, onClick: () => load(page - 1) }, "上一页"),
|
||
react.createElement("button", { type: "button", className: cssMap.btn, disabled: page >= totalPages || loading, onClick: () => load(page + 1) }, "下一页"));
|
||
// V1.0 同步:数据分析 tab 面板(account_analysis 最新 md;空态引导)
|
||
const analysisPanel = react.createElement("div", { className: cssMap.tabPanel + " " + cssMap.personaPanel },
|
||
analysisLoading
|
||
? react.createElement("div", { className: cssMap.tabState }, "加载中…")
|
||
: analysisError
|
||
? react.createElement("div", { className: cssMap.tabState }, "加载失败:" + analysisError)
|
||
: analysisData && analysisData.content
|
||
? react.createElement("div", { className: cssMap.md }, mdToReact(analysisData.content))
|
||
: react.createElement("div", { className: cssMap.tabState },
|
||
react.createElement("div", null, "该账号暂无账号数据分析"),
|
||
react.createElement("div", { style: { marginTop: 8, fontSize: 12, lineHeight: 1.6, opacity: 0.85 } },
|
||
"数据分析(账号内容结构拆解与定位诊断报告)生成后在此展示。可在对话中发起「分析这个账号的视频数据」,将按 mcn-dou-analysis 功能四执行并自动落库。")),
|
||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, style: { marginTop: 12, alignSelf: "flex-start" }, disabled: analysisBusy || accountId == null, title: analysisBusy ? "该账号数据分析任务执行中(约 10-20 分钟),可先处理其他账号" : "调用抖音账号分析技能执行账号数据分析(功能四:样本拆解 → 8 章报告)", onClick: submitAccountAnalysis }, analysisBusy ? "分析中…" : "发起账号数据分析"),
|
||
);
|
||
// V1.0 同步:视频互动趋势区块(近半年点赞/评论,lieflat 风格内联 SVG;不足 2 点或加载中不显示)
|
||
const trendBlock = trendPoints == null ? null
|
||
: trendPoints.length >= 2
|
||
? react.createElement("div", { style: { border: "1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))", borderRadius: 8, padding: "8px 12px 6px", marginBottom: 12, background: "var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))" } },
|
||
react.createElement("div", { style: { display: "flex", alignItems: "center", gap: 14, marginBottom: 4, fontSize: 12 } },
|
||
react.createElement("span", { style: { color: "var(--dsw-alias-label-primary)" } }, "互动趋势"),
|
||
react.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, color: "#e5484d" } }, "● 点赞"),
|
||
react.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, color: "#2f6fed" } }, "● 评论"),
|
||
react.createElement("span", { style: { marginLeft: "auto", color: "var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))" } }, "近半年 · 按发布时间")),
|
||
trendSvg(trendPoints))
|
||
: (total > 0 ? react.createElement("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#8a8f98)", marginBottom: 12 } }, "近半年互动数据不足 2 条,暂不展示趋势") : null);
|
||
|
||
if (account) {
|
||
infoCard = react.createElement("div", { className: cssMap.infoCard },
|
||
react.createElement("div", { className: cssMap.tabs, role: "tablist" },
|
||
tabBtn("账号信息", "info"),
|
||
tabBtn("视频列表", "persona"),
|
||
tabBtn("数据分析", "analysis")),
|
||
activeTab === "info"
|
||
? react.createElement("div", { className: cssMap.tabPanel },
|
||
react.createElement("div", { className: cssMap.infoGrid },
|
||
infoItem("抖音号", fmt(account.douyin_id)),
|
||
infoItem("赛道", fmt(account.track)),
|
||
infoItem("AI制作", account.is_ai === "是" ? "是" : "否"),
|
||
infoItem("粉丝", fmt(account.followers)),
|
||
infoItem("获赞", fmt(account.total_likes)),
|
||
infoItem("作品数", fmt(account.works_count)),
|
||
infoItem("地区", fmt(account.location)),
|
||
infoItem("年龄", fmt(account.age)),
|
||
infoItem("获取视频", fmt(total)),
|
||
infoItem("简介", fmt(account.bio), true),
|
||
infoItem("定位/内容", fmt(account.content), true)),
|
||
react.createElement("div", { style: { margin: "18px 0 10px", paddingTop: 12, borderTop: "1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))", fontWeight: 600, fontSize: 15, color: "var(--dsw-alias-label-primary)" } }, "账号设定"),
|
||
personaLoading
|
||
? react.createElement("div", { className: cssMap.tabState }, "加载中…")
|
||
: personaError
|
||
? react.createElement("div", { className: cssMap.tabState }, "加载失败:" + personaError)
|
||
: personaData && personaData.content
|
||
? react.createElement("div", { className: cssMap.md }, mdToReact(personaData.content))
|
||
: react.createElement("div", { className: cssMap.tabState },
|
||
react.createElement("div", null, "该账号暂无账号设定信息"),
|
||
react.createElement("button", {
|
||
type: "button",
|
||
className: cssMap.btn + " " + cssMap.btnPrimary,
|
||
style: { marginTop: 12 },
|
||
disabled: personaBusy,
|
||
title: personaBusy ? "该账号正在提炼账号设定中(约 10-20 分钟),可先处理其他账号" : "调用抖音账号分析技能执行提炼账号设定(功能三:筛选TOP4→MCP解析→生成人设卡)",
|
||
onClick: () => setPersonaRow(account),
|
||
}, personaBusy ? "提炼中…" : "提炼账号设定")))
|
||
: activeTab === "analysis"
|
||
? analysisPanel
|
||
: react.createElement("div", { className: cssMap.tabPanel },
|
||
trendBlock,
|
||
videoTable,
|
||
pager));
|
||
}
|
||
}
|
||
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() }, "←"),
|
||
account ? react.createElement("span", { className: cssMap.title }, account.account_name || "账号详情") : react.createElement("span", { className: cssMap.title }, "账号详情"),
|
||
toast ? react.createElement("span", { className: cssMap.pagerInfo }, toast) : null),
|
||
infoCard || tableArea),
|
||
benchmarkRow && react.createElement("div", { className: cssMap.mask, onClick: () => setBenchmarkRow(null) },
|
||
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
|
||
react.createElement("div", { className: cssMap.modalTitle }, "确认改写脚本"),
|
||
react.createElement("div", { className: cssMap.modalText },
|
||
react.createElement("div", null, "视频:", benchmarkRow.video_title || "-"),
|
||
react.createElement("div", null, "视频ID:", benchmarkRow.aweme_id || "-"),
|
||
react.createElement("div", null, `将基于账号设定与该视频选题,在会话 dsh_rewrite 中创作一条全新的故事脚本(非分镜、不对标分析)。同一会话串行执行,运行中不可再次触发。`)),
|
||
react.createElement("div", { className: cssMap.modalBtns },
|
||
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setBenchmarkRow(null) }, "取消"),
|
||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: confirmBenchmark }, "确认创作")))),
|
||
analyzeRow && react.createElement("div", { className: cssMap.mask, onClick: () => setAnalyzeRow(null) },
|
||
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
|
||
react.createElement("div", { className: cssMap.modalTitle }, "确认解析视频"),
|
||
react.createElement("div", { className: cssMap.modalText },
|
||
react.createElement("div", null, "视频:", analyzeRow.video_title || "-"),
|
||
react.createElement("div", null, "视频ID:", analyzeRow.aweme_id || "-"),
|
||
react.createElement("div", null, "将调用抖音账号分析技能解析本视频(MCP 解析,约 3-5 分钟),解析结果保存到本地并写入数据库。确认解析?")),
|
||
react.createElement("div", { className: cssMap.modalBtns },
|
||
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setAnalyzeRow(null) }, "取消"),
|
||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: submitAnalyze }, "确认解析")))),
|
||
personaRow && react.createElement("div", { className: cssMap.mask, onClick: () => setPersonaRow(null) },
|
||
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
|
||
react.createElement("div", { className: cssMap.modalTitle }, "确认提炼账号设定"),
|
||
react.createElement("div", { className: cssMap.modalText },
|
||
react.createElement("div", null, "账号:", personaRow.account_name || "-"),
|
||
react.createElement("div", null, "将调用抖音账号分析技能执行提炼账号设定(功能三:筛选 TOP4 视频 → MCP 解析 → 生成人设卡,约 10-20 分钟),结果保存到本地并写入数据库。确认执行?")),
|
||
react.createElement("div", { className: cssMap.modalBtns },
|
||
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setPersonaRow(null) }, "取消"),
|
||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: submitPersonaExtract }, "确认提炼")))));
|
||
}
|
||
//#endregion
|
||
//#region plugin entry:注册到 MCN 工作台入口注册表(hidden:不显示在工作台网格,仅程序化导航打开)
|
||
const inject = [];
|
||
function apply() {
|
||
window.__mcnEntries = window.__mcnEntries || [];
|
||
// 幂等:避免插件热重载重复注册
|
||
if (!window.__mcnEntries.some((e) => e.id === "douyin-account-detail")) {
|
||
window.__mcnEntries.push({
|
||
id: "douyin-account-detail",
|
||
icon: IconChart,
|
||
title: "账号详情",
|
||
desc: "账号信息与视频列表(从账号列表进入)",
|
||
hidden: true,
|
||
component: AccountDetailPage,
|
||
});
|
||
}
|
||
}
|
||
//#endregion
|
||
exports.apply = apply;
|
||
exports.inject = inject;
|
||
return module.exports;
|
||
}
|
||
}); |