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
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
});
|