baseline: dsh 工作区基线快照(2026-09-10,全新历史起点)
本提交为 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 本地已不存在,本提交记为删除
This commit is contained in:
commit
856e632807
880 files changed
+225426
No files matched your search
+7
@@ -0,0 +1,7 @@
|
||||
# dsh-plugin-douyin-account-detail bundle layer: registers the plugin row.
|
||||
# The detail page registers into the MCN workspace entry registry
|
||||
# (window.__mcnEntries, hidden: true) and is opened programmatically
|
||||
# from the account list row (__mcnNav.open('douyin-account-detail', ...)).
|
||||
- insert:
|
||||
- id: douyin-account-detail
|
||||
name: dsh-plugin-douyin-account-detail
|
||||
+878
@@ -0,0 +1,878 @@
|
||||
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(--dLine truncated
|
||||
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;
|
||||
}
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// dsh-plugin-douyin-account-detail 宿主端入口
|
||||
// 纯客户端 UI 插件:详情页通过 ./client 导出加载,
|
||||
// 数据复用 dsh-plugin-mcn 服务端路由(/mcn/api/account/videos)。
|
||||
// 服务端无需注册路由,仅需一个空 apply 以满足 cordis 插件契约。
|
||||
function apply() {}
|
||||
|
||||
const inject = [];
|
||||
|
||||
export { apply, inject };
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "dsh-plugin-douyin-account-detail",
|
||||
"version": "0.1.0",
|
||||
"description": "抖音账号详情(账号信息 + 视频列表分页) - MCN 工作台功能插件",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"cordis.patch.yml"
|
||||
],
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": []
|
||||
},
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user