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:
admin committed 2026-09-10 22:31:45 +08:00
commit 856e632807
880 files changed
+225426

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# Your patch layer for this dsh profile, applied after every bundle layer:
# a top-level YAML array of loader patch entries (id-targeted config
# overrides, disables, and insert lists; `!!js` expressions allowed).
- id: skill-filesystem
disabled: false
config:
customSkillDirs:
- D:/dshworkspace/dsh-plugin-dev
# AnySearch unified real-time search MCP (remote streamable-http, key auth).
- insert:
- id: mcp-anysearch
name: '@deepseek-ai/dsh-mcp-client'
config:
transport: streamable-http
serverName: anysearch
url: https://api.anysearch.com/mcp
headers:
Authorization: Bearer as_sk_5fd07d3b88a0a7b96bc54724c8d4cde5
X-Anysearch-Client: mcp/1.0.0
+4
View File
@@ -0,0 +1,4 @@
# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
@@ -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
@@ -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;
}
});
@@ -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 };
@@ -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"
}
}
}
@@ -0,0 +1,6 @@
# dsh-plugin-douyin-accounts bundle layer: registers the plugin row.
# The feature page registers itself into the MCN workspace entry registry
# (window.__mcnEntries) so the workspace home grid shows it automatically.
- insert:
- id: douyin-accounts
name: dsh-plugin-douyin-accounts
+666
View File
@@ -0,0 +1,666 @@
window.__ModuleLoader__.load({
id: "dsh-plugin-douyin-accounts",
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 = ".dvAcc_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;grid-template-rows:minmax(0,1fr);display:grid;overflow:hidden}.dvAcc_body{display:flex;flex-direction:column;height:100%;min-height:0;padding:16px;box-sizing:border-box;gap:12px}.dvAcc_toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dvAcc_title{font-size:16px;font-weight:600;white-space:nowrap}.dvAcc_select{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 8px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary)}.dvAcc_input{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 10px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);min-width:200px}.dvAcc_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)}.dvAcc_btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dvAcc_link{color:var(--dsw-alias-accent-fg);cursor:pointer;text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvAcc_link:hover{text-decoration:underline}.dvAcc_spacer{flex:1}.dvAcc_tableWrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px}.dvAcc_table{width:100%;border-collapse:collapse;font:var(--dsw-font-s-14);min-width:1360px}.dvAcc_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}.dvAcc_table td{padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));vertical-align:middle}.dvAcc_table tr:hover{background:var(--dsw-alias-interactive-bg-hover)}.dvAcc_cellName{font-weight:600;white-space:nowrap}.dvAcc_cellTrack{white-space:nowrap}.dvAcc_cellContent{color:var(--dsw-alias-label-secondary);max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvAcc_cellClamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal;word-break:break-all;line-height:1.5}.dvAcc_cellM{white-space:nowrap;text-align:center}.dvAcc_badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;line-height:18px;white-space:nowrap}.dvAcc_badgeSigned{background:rgba(93,125,255,.18);color:var(--dsw-alias-label-primary)}.dvAcc_badgeExternal{background:rgba(46,190,140,.18);color:var(--dsw-alias-label-primary)}.dvAcc_pager{display:flex;align-items:center;gap:12px;justify-content:flex-end}.dvAcc_pagerTotal{margin-right:auto;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-13)}.dvAcc_pagerInfo{color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-13)}.dvAcc_loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-14)}.dvAcc_error{color:#ff6b6b;padding:20px;font:var(--dsw-font-s-14)}.dvAcc_empty{padding:40px 20px;text-align:center;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-14);line-height:1.8}.dvAcc_btnPrimary{background:var(--dsw-alias-brand-primary,#4d6bfe);color:#fff;border-color:transparent}.dvAcc_btnPrimary:hover{background:var(--dsw-alias-brand-primary-hover,#3a55d6)}.dvAcc_btnIcon{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;margin-right:4px;border-radius:6px;font-size:14px}.dvAcc_mask{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:flex-start;justify-content:center;padding-top:28vh;z-index:100}.dvAcc_modal{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border-radius:12px;padding:20px;min-width:320px;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,.2)}.dvAcc_modalEdit{max-width:720px;width:92vw;display:flex;flex-direction:column;max-height:78vh}.dvAcc_modalBody{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:12px;padding-right:4px}.dvAcc_formGrid{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}.dvAcc_formItem{display:flex;flex-direction:column;gap:4px;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary)}.dvAcc_formItemWide{grid-column:1/-1}.dvAcc_textarea{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 10px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))Line truncated
const key = "dsh-plugin-douyin-accounts/DouyinAccounts.module.css";
if (!document.querySelector("style[data-plugin-css=" + JSON.stringify(key) + "]")) {
const el = document.createElement("style");
el.dataset.plugin = "dsh-plugin-douyin-accounts";
el.dataset.pluginCss = key;
el.textContent = css;
if (!document.querySelector('style[data-plugin-css="dvAcc-align"]')) {
const el2 = document.createElement("style");
el2.dataset.pluginCss = "dvAcc-align";
el2.textContent = ".dvAcc_table thead th:last-child{text-align:center!important}.dvAcc_table tbody td:last-child{text-align:center!important}.tblAcc thead th:nth-child(7),.tblAcc thead th:nth-child(8),.tblAcc thead th:nth-child(9),.tblAcc thead th:nth-child(10),.tblAcc tbody td:nth-child(7),.tblAcc tbody td:nth-child(8),.tblAcc tbody td:nth-child(9),.tblAcc tbody td:nth-child(10){text-align:right}.tblAcc thead th:nth-child(1),.tblAcc thead th:nth-child(11),.tblAcc thead th:nth-child(12),.tblAcc tbody td:nth-child(1),.tblAcc tbody td:nth-child(11),.tblAcc tbody td:nth-child(12){text-align:center}.dvAcc_tableWrap{overflow:auto!important;min-width:0!important}.dvAcc_table{min-width:1180px}.tblAcc thead th:nth-child(5),.tblAcc tbody td:nth-child(5){max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dvAcc_table{width:auto!important;min-width:0!important;table-layout:auto}.dvAcc_page{width:100%!important;min-width:0!important;max-width:100%!important}.dvAcc_body{width:100%!important;min-width:0!important;max-width:100%!important}.dvAcc_tableWrap{width:100%!important;min-width:0!important;max-width:100%!important;overflow:auto}";
document.head.appendChild(el2);
}
document.head.appendChild(el);
}
const cssMap = {
"page": "dvAcc_page",
"body": "dvAcc_body",
"toolbar": "dvAcc_toolbar",
"title": "dvAcc_title",
"select": "dvAcc_select",
"input": "dvAcc_input",
"btn": "dvAcc_btn",
"link": "dvAcc_link",
"spacer": "dvAcc_spacer",
"tableWrap": "dvAcc_tableWrap",
"table": "dvAcc_table",
"cellName": "dvAcc_cellName",
"cellTrack": "dvAcc_cellTrack",
"cellContent": "dvAcc_cellContent",
"cellClamp": "dvAcc_cellClamp",
"cellM": "dvAcc_cellM",
"badge": "dvAcc_badge",
"badgeSigned": "dvAcc_badgeSigned",
"badgeExternal": "dvAcc_badgeExternal",
"pager": "dvAcc_pager",
"pagerTotal": "dvAcc_pagerTotal",
"pagerInfo": "dvAcc_pagerInfo",
"loading": "dvAcc_loading",
"error": "dvAcc_error",
"empty": "dvAcc_empty",
"mask": "dvAcc_mask",
"modal": "dvAcc_modal",
"modalTitle": "dvAcc_modalTitle",
"modalText": "dvAcc_modalText",
"modalBtns": "dvAcc_modalBtns",
"btnPrimary": "dvAcc_btnPrimary",
"btnIcon": "dvAcc_btnIcon",
"modalEdit": "dvAcc_modalEdit",
"modalBody": "dvAcc_modalBody",
"formGrid": "dvAcc_formGrid",
"formItem": "dvAcc_formItem",
"formItemWide": "dvAcc_formItemWide",
"textarea": "dvAcc_textarea"
};
//#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", marginRight: "4px" },
}, 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 IconRefresh = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("path", { key: "p1", d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }), react.createElement("path", { key: "p2", d: "M21 3v5h-5" }), react.createElement("path", { key: "p3", d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }), react.createElement("path", { key: "p4", d: "M8 16H3v5" })] });
const IconEdit = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("path", { key: "p1", d: "M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" })] });
const IconGlobe = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("circle", { key: "p1", cx: "12", cy: "12", r: "10" }), react.createElement("path", { key: "p2", d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }), react.createElement("path", { key: "p3", d: "M2 12h20" })] });
const IconCheckSquare = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("rect", { key: "p1", x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }), react.createElement("path", { key: "p2", d: "m9 12 2 2 4-4" })] });
const IconUsers = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("path", { key: "p1", d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }), react.createElement("circle", { key: "p2", cx: "9", cy: "7", r: "4" }), react.createElement("path", { key: "p3", d: "M22 21v-2a4 4 0 0 0-3-3.87" }), react.createElement("path", { key: "p4", d: "M16 3.13a4 4 0 0 1 0 7.75" })] });
// 账号类型:all 全部 / signed 签约(MCP 数据)/ external 外部(skill 数据,结构待确认)
const TYPE_OPTIONS = [
{ value: "all", label: "全部" },
{ value: "signed", label: "内部" },
{ value: "external", label: "外部" }
];
const TYPE_LABEL = { signed: "内部", external: "外部" };
// 长文本缩短为约 1/3(上限 150 字),单元格保持单行省略展示;悬浮 title 仍为全文
const shortText = (s) => {
if (!s) return "-";
const full = String(s);
const n = Math.min(Math.ceil(full.length / 3), 150);
return full.length > n ? full.slice(0, n) + "…" : full;
};
function AccountsPage() {
const [type, setType] = react.useState("all"); // 默认全部
const [search, setSearch] = react.useState("");
const [track, setTrack] = react.useState("");
const [tracks, setTracks] = react.useState([]);
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(true);
const [error, setError] = react.useState(null);
const [toast, setToast] = react.useState(null);
const [importDirOpen, setImportDirOpen] = react.useState(false);
const [importDirInput, setImportDirInput] = react.useState("");
// 网页获取账号信息(抖音账号分析技能):账号名称 / 抖音号 / 抖音账号地址
const [importAccName, setImportAccName] = react.useState("");
const [importAccDid, setImportAccDid] = react.useState("");
const [importAccUrl, setImportAccUrl] = react.useState("");
const [importing, setImporting] = react.useState(false);
// 编辑账号信息弹窗状态
const [editRow, setEditRow] = react.useState(null);
const [editForm, setEditForm] = react.useState(null);
const [saving, setSaving] = react.useState(false);
// 任务状态轮询(creative/import 执行中 → 按钮置灰;导入/刷新完成后自动刷新列表)
const [taskStatus, setTaskStatus] = react.useState({ creative: "idle", import: "idle" });
// 等待 AI 会话处理完成的标记:处理后自动刷新列表数据
const [pendingRefresh, setPendingRefresh] = react.useState(false);
// V1.0 同步:批量栏 + 行内 AI创作(原创选题)状态
const [batchOn, setBatchOn] = react.useState(false);
const [selected, setSelected] = react.useState({});
const [createRow, setCreateRow] = react.useState(null);
const [createText, setCreateText] = react.useState("");
const [creating, setCreating] = react.useState(false);
// V1.0 对齐:更新关注账号(批量串行)状态
const [updBusy, setUpdBusy] = react.useState(false);
const [updLabel, setUpdLabel] = react.useState("");
const prevImportRef = react.useRef("idle");
// 刷新确认弹窗:待刷新账号
const [refreshRow, setRefreshRow] = react.useState(null);
react.useEffect(() => {
let alive = true;
const poll = () => {
fetch("/mcn/api/task/status")
.then((r) => r.json())
.then((d) => {
if (!alive) return;
const st = { creative: d.creative || "idle", import: d.import || "idle" };
setTaskStatus(st);
// 导入/刷新会话从 running → idle:AI 处理完成,刷新列表数据
if (pendingRefresh && prevImportRef.current === "running" && st.import === "idle") {
setPendingRefresh(false);
setToast("数据处理完成,列表已刷新");
load(1, type, search, track);
}
prevImportRef.current = st.import;
})
.catch(() => {});
};
poll();
const timer = setInterval(poll, 5000);
return () => { alive = false; clearInterval(timer); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingRefresh]);
const creativeBusy = taskStatus.creative === "running";
const importBusy = taskStatus.import === "running";
const loadTracks = () => {
fetch("/mcn/api/tracks").then((r) => r.json()).then((d) => {
if (d.ok) setTracks(d.tracks || []);
}).catch(() => {});
};
const load = (p = page, t = type, s = search, tr = track) => {
setLoading(true);
const params = new URLSearchParams({ page: String(p), pageSize: String(pageSize), type: t, sortBy: "id", order: "asc" });
if (s) params.set("search", s);
if (tr) params.set("track", tr);
fetch("/mcn/api/accounts?" + params.toString())
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "加载失败");
setItems(d.items || []);
setTotal(d.total || 0);
setPage(d.page || 1);
setError(null);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
};
// 收藏/取消收藏:成功后刷新列表(收藏的账号置顶,按粉丝数降序)
const toggleFavorite = (row) => {
fetch("/mcn/api/account/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId: row.id, favorite: row.favorite ? 0 : 1 }),
})
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "操作失败");
setToast(row.favorite ? `已取消收藏「${row.account_name}」` : `已收藏「${row.account_name}」`);
load(1, type, search, track);
})
.catch((e) => setToast(e.message || "操作失败"));
};
react.useEffect(() => {
loadTracks();
load(1, "all", "", "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
react.useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}, [toast]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const onTypeChange = (v) => { setType(v); load(1, v, search, track); };
const onSearch = () => { load(1, type, search, track); };
const onReset = () => { setSearch(""); setTrack(""); setType("all"); load(1, "all", "", ""); };
const createFor = (row) => {
const info = [
`账号名:${row.account_name}`,
row.track ? `赛道:${row.track}` : "",
row.content ? `账号定位:${row.content}` : "",
].filter(Boolean).join(";");
const prompt = `请为达人「${row.account_name}」创作一条短视频脚本。\n达人信息:${info}\n请先进行账号设定分析,再生成完整脚本。`;
fetch("/mcn/api/creative", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
})
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "触发失败");
setToast(d.message || "已开始创作,请切换到对应会话查看");
})
.catch((e) => setToast("创作触发失败: " + e.message));
};
const typeBadge = (t) => {
const label = TYPE_LABEL[t] || "签约";
const cls = t === "external" ? cssMap.badge + " " + cssMap.badgeExternal : cssMap.badge + " " + cssMap.badgeSigned;
return react.createElement("span", { className: cls }, label);
};
// 编辑账号信息:打开弹窗(账号名称不可改)
const openEdit = (row) => {
setEditRow(row);
setEditForm({
track: row.track ?? "",
is_ai: row.is_ai === "是" ? "是" : "否",
content: row.content ?? "",
tags: row.tags ?? "",
masterpiece: row.masterpiece ?? "",
douyin_id: row.douyin_id ?? "",
followers: row.followers ?? "",
total_likes: row.total_likes ?? "",
works_count: row.works_count ?? "",
ip_location: row.ip_location ?? "",
location: row.location ?? "",
age: row.age ?? "",
bio: row.bio ?? "",
});
};
const saveEdit = () => {
if (!editRow) return;
setSaving(true);
const payload = { id: editRow.id };
for (const k of Object.keys(editForm)) {
const val = String(editForm[k] ?? "").trim();
if (k === "works_count" || k === "age") {
payload[k] = val === "" ? null : (Number.isFinite(Number(val)) ? Number(val) : null);
} else {
payload[k] = val === "" ? null : val;
}
}
fetch("/mcn/api/account/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "保存失败");
setToast(d.message || "已保存");
setEditRow(null);
setEditForm(null);
load(1, type, search, track);
})
.catch((e) => setToast("保存失败: " + e.message))
.finally(() => setSaving(false));
};
// 编辑表单字段控件(label + input/textarea)
const editField = (label, key, opts) => {
const o = opts || {};
const cls = cssMap.formItem + (o.wide ? " " + cssMap.formItemWide : "");
const control = o.textarea
? react.createElement("textarea", {
className: cssMap.textarea,
rows: o.rows || 3,
value: editForm[key] ?? "",
onChange: (e) => setEditForm({ ...editForm, [key]: e.target.value }),
})
: react.createElement("input", {
className: cssMap.input,
type: o.type || "text",
style: { boxSizing: "border-box", width: "100%" },
value: editForm[key] ?? "",
onChange: (e) => setEditForm({ ...editForm, [key]: e.target.value }),
});
return react.createElement("label", { className: cls, key }, react.createElement("span", null, label), control);
};
// 导入:支持两种方式(AI 判断处理)
// 1) 本地文件夹路径 → 提交给宿主端 → AI 读取分析确认后导入
// 2) 抖音账号信息(名称/抖音号/地址)→ AI 调用抖音账号分析技能网页获取账号信息与视频列表并写入数据库
const submitImport = () => {
const dir = importDirInput.trim();
const accName = importAccName.trim();
const accDid = importAccDid.trim();
const accUrl = importAccUrl.trim();
if (!dir && !accName && !accDid && !accUrl) { setToast("请输入文件夹路径或抖音账号信息"); return; }
setImporting(true);
const body = dir
? { dir }
: { account: { name: accName, douyinId: accDid, url: accUrl } };
fetch("/mcn/api/external/stage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "提交失败");
setToast(d.message || (dir ? "已提交,AI 正在分析文件结构,请到对话中确认导入" : "已提交,AI 正在获取账号信息与视频列表,请到对话中查看"));
setImportDirOpen(false);
setImportDirInput("");
setImportAccName("");
setImportAccDid("");
setImportAccUrl("");
// AI 处理完成后自动刷新列表
setPendingRefresh(true);
})
.catch((err) => setToast("提交失败: " + err.message))
.finally(() => setImporting(false));
};
// 刷新账号数据:确认弹窗 → AI 会话(mcn-dou-analysis 技能)重新获取并更新数据库
const submitRefresh = () => {
const row = refreshRow;
if (!row) return;
setRefreshRow(null);
fetch("/mcn/api/account/refresh-web", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: row.id }),
})
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "提交失败");
setToast(d.message || "已提交刷新,AI 正在重新获取数据");
// AI 处理完成后自动刷新列表
setPendingRefresh(true);
})
.catch((err) => setToast("刷新提交失败: " + err.message));
};
// V1.0 对齐:更新所有已关注账号(⭐)——逐账号串行 refresh-web,等待各自完成;进度与汇总提示
const updateFollowedAccounts = () => {
if (updBusy) return;
fetch("/mcn/api/accounts?type=all&page=1&pageSize=100")
.then((r) => r.json())
.then((d) => {
const favs = ((d.items || [])).filter((a) => a.favorite);
if (!favs.length) { setToast("⭐ 暂无已关注账号(先点击列表中的 ☆ 关注)"); return; }
const names = favs.map((a) => String(a.account_name || "").trim()).filter(Boolean);
if (!names.length) { setToast("已关注账号缺少账号名,无法更新"); return; }
if (!window.confirm("将重新抓取 " + names.length + " 个已关注账号(⭐)的最新账号数据与视频列表(后台逐个执行,每个需数分钟;账号身份标识以本次抓取为准,强制覆盖刷新)。\n账号:" + names.slice(0, 8).join("、") + (names.length > 8 ? " 等" : ""))) return;
setUpdBusy(true);
const done = []; const failed = [];
const loop = (i) => {
if (i >= favs.length) {
setUpdBusy(false); setUpdLabel("");
setToast(done.length ? "✅ 已更新 " + done.length + " 个关注账号" + (failed.length ? ";" + failed.length + " 个未完成:" + failed.join("、") : "") : "❌ 更新未完成:" + failed.join("、"));
load(1, type, search, track);
return;
}
const id = favs[i].id;
setUpdLabel("更新中 " + (i + 1) + "/" + favs.length + "…");
fetch("/mcn/api/account/refresh-web", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: id }) })
.then((r) => r.json())
.then((d2) => {
if (!d2.ok) { failed.push(names[i] + "(" + (d2.error || "提交失败") + ")"); loop(i + 1); return; }
const deadline = Date.now() + 20 * 60 * 1000;
const wait = () => {
fetch("/mcn/api/task/status")
.then((r2) => r2.json())
.then((st) => {
const ilist = Array.isArray(st.imports) ? st.imports : [];
const t = ilist.find((x) => x.id === "refresh:" + id);
const running = !!(t && t.status === "running");
if (!running || Date.now() > deadline) { done.push(names[i]); loop(i + 1); return; }
setTimeout(wait, 5000);
})
.catch(() => { done.push(names[i]); loop(i + 1); });
};
wait();
})
.catch((e) => { failed.push(names[i] + "(" + e.message + ")"); loop(i + 1); });
};
loop(0);
})
.catch((e) => setToast("获取账号列表失败: " + e.message));
};
// ---- V1.0 同步:批量操作(勾选→连坐删除)/ AI脚本列跳转 / 行内 AI创作(原创选题) ----
const openScriptsOf = (row) => {
try {
if (window.__mcnNav && typeof window.__mcnNav.openView === "function") {
window.__mcnNav.openView("ai-videos", { accountId: row.id, accountName: row.account_name });
} else { setToast("请先打开工作台「AI写脚本」后重试"); }
} catch (e) { /* 导航失败忽略 */ }
};
const toggleSelect = (id) => setSelected((prev) => { const n = Object.assign({}, prev); if (n[id]) delete n[id]; else n[id] = true; return n; });
const pageAllSelected = items.length > 0 && items.every((r) => !!selected[r.id]);
const toggleSelectAll = () => {
const n = Object.assign({}, selected);
if (pageAllSelected) items.forEach((r) => { delete n[r.id]; }); else items.forEach((r) => { n[r.id] = true; });
setSelected(n);
};
const selCount = Object.keys(selected).filter((k) => selected[k]).length;
const exitBatch = () => { setBatchOn(false); setSelected({}); };
const submitBatchDelete = () => {
const ids = Object.keys(selected).filter((k) => selected[k]).map(Number);
if (!ids.length) { setToast("请先勾选要删除的账号"); return; }
if (!window.confirm("将删除所选 " + ids.length + " 个账号及其视频数据、视频解析、账号设定、数据分析、AI脚本与复盘数据,不可恢复。确认删除?")) return;
fetch("/mcn/api/batch/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "accounts", ids: ids }),
})
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "删除失败");
setToast(d.message || ("已删除 " + d.deleted + " 个账号(含关联解析/复盘/分镜)"));
exitBatch();
load(1, type, search, track);
})
.catch((e) => setToast("删除失败: " + e.message));
};
const openCreate = (row) => { setCreateRow(row); setCreateText(""); };
const submitCreate = () => {
const row = createRow;
if (!row || creating) return;
setCreating(true);
fetch("/mcn/api/rewrite/original", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId: row.id, reqText: createText.trim() }),
})
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "提交失败");
setToast(d.message || "已提交原创选题创作,完成后自动入库到「AI写脚本 → 原创选题创作」");
setCreateRow(null); setCreateText("");
})
.catch((e) => setToast("创作提交失败: " + e.message))
.finally(() => setCreating(false));
};
const toolbar = react.createElement("div", { className: cssMap.toolbar },
react.createElement("button", {
type: "button",
className: cssMap.btn,
title: "返回工作台",
onClick: () => { try { window.__mcnNav && window.__mcnNav.goHome(); } catch (e) {} },
}, "←"),
react.createElement("span", { className: cssMap.title }, "账号列表"),
);
const filterRow = react.createElement("div", { className: cssMap.toolbar, style: { flexWrap: "wrap" } }, react.createElement("select", {
className: cssMap.select,
value: type,
onChange: (e) => onTypeChange(e.target.value),
title: "账号类型",
}, TYPE_OPTIONS.map((o) => react.createElement("option", { value: o.value, key: o.value }, o.label))),
react.createElement("div", { style: { marginLeft: "auto", flex: "1 1 0%", minWidth: 0, display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" } },
react.createElement("input", {
className: cssMap.input,
placeholder: "搜索账号名 / 内容 / 赛道",
value: search,
onChange: (e) => setSearch(e.target.value),
onKeyDown: (e) => { if (e.key === "Enter") onSearch(); },
}),
react.createElement("select", {
className: cssMap.select,
value: track,
onChange: (e) => { setTrack(e.target.value); load(1, type, search, e.target.value); },
}, [react.createElement("option", { value: "", key: "" }, "全部赛道")].concat(tracks.map((t) => react.createElement("option", { value: t, key: t }, t)))),
type !== "signed" && react.createElement("button", {
type: "button",
className: cssMap.btn,
title: importBusy ? "导入分析任务执行中,请等待完成" : "导入外部账号数据(输入文件夹路径)",
onClick: () => setImportDirOpen(true),
disabled: importing || importBusy,
}, importing || importBusy ? "导入执行中…" : react.createElement(react.Fragment, null, react.createElement(IconDownload, {}), "添加")),
react.createElement("button", { type: "button", className: cssMap.btn, disabled: updBusy, title: updBusy ? "正在更新关注账号…" : "重新抓取所有已关注账号(⭐)的最新账号数据与视频列表(后台任务逐个执行)", onClick: updateFollowedAccounts }, updBusy ? updLabel : react.createElement(IconGlobe, { size: 15 })),
toast ? react.createElement("span", { className: cssMap.pagerInfo }, toast) : null));let tableBody;
if (loading) {
tableBody = react.createElement("div", { className: cssMap.loading }, "加载中…");
} else if (error) {
tableBody = react.createElement("div", { className: cssMap.error }, error);
} else if (items.length === 0) {
tableBody = type === "external"
? react.createElement("div", { className: cssMap.empty }, "暂无外部账号数据")
: react.createElement("div", { className: cssMap.empty }, "暂无数据");
} else {
// 首页排行榜同款“弹性行”排版:左组紧凑靠左、中间弹性空隙、右组紧凑靠右(列宽不摊开)
const C = (al, clip) => ({ padding: "9px 10px", borderBottom: "1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))", textAlign: al || "left", minWidth: 0, whiteSpace: "nowrap", overflow: clip ? "hidden" : undefined, textOverflow: clip ? "ellipsis" : undefined, boxSizing: "border-box" });
const HST = { padding: "9px 10px", textAlign: "center", borderBottom: "1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))", color: "var(--dsw-alias-label-secondary,#6b7280)", fontWeight: 600, fontSize: 13, whiteSpace: "nowrap" };
const template = batchOn ? "36px 50px 66px 120px 120px 48px minmax(56px,1fr) 68px 70px 62px 52px 66px 110px" : "50px 66px 120px 120px 48px minmax(56px,1fr) 68px 70px 62px 52px 66px 110px";
const headCells = [];
const rowCells = (row) => {
const cells = [];
if (batchOn) cells.push(react.createElement("div", { key: "b", style: C("center") }, react.createElement("input", { type: "checkbox", checked: !!selected[row.id], onChange: () => toggleSelect(row.id) })));
cells.push(
react.createElement("div", { key: "f", style: C("center") }, react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnSm, title: row.favorite ? "取消收藏(点击后从置顶移除)" : "收藏该账号(收藏的账号排在列表最前,按粉丝数降序)", onClick: () => toggleFavorite(row), style: row.favorite ? { color: "#f5b301", borderColor: "#f5b30166" } : { color: "var(--dsw-alias-label-secondary,#8a8f98)" } }, row.favorite ? "★" : "☆")),
react.createElement("div", { key: "ty", style: C("left") }, typeBadge(row.account_type)),
react.createElement("div", { key: "nm", style: Object.assign(C("left"), { whiteSpace: "normal", wordBreak: "break-all" }) }, react.createElement("span", { className: cssMap.link, title: "查看账号详情(账号信息 + 视频列表)", style: { cursor: "pointer", color: "var(--mcn-brand,#4176e6)", fontWeight: 600 }, onClick: () => window.__mcnNav?.open("douyin-account-detail", { accountId: row.id, accountName: row.account_name }) }, row.account_name)),
react.createElement("div", { key: "tr", style: Object.assign(C("left"), { whiteSpace: "normal", wordBreak: "break-all" }) }, row.track || "-"),
react.createElement("div", { key: "ai", style: C("left") }, row.is_ai === "是" ? "是" : "否"),
react.createElement("div", { key: "bj", style: C("left", true) }, row.bio || "-"),
react.createElement("div", { key: "f1", style: C("right") }, row.followers ?? "-"),
react.createElement("div", { key: "f2", style: C("right") }, row.total_likes ?? "-"),
react.createElement("div", { key: "f3", style: C("right") }, row.works_count ?? "-"),
react.createElement("div", { key: "f4", style: C("right") }, row.video_count ?? 0),
react.createElement("div", { key: "as", style: C("center", true) }, row.rewrite_count > 0 ? react.createElement("span", { className: cssMap.link, style: { cursor: "pointer", color: "var(--mcn-brand,#4176e6)" }, title: "该账号 AI 脚本数(参考选题+原创选题),点击进入 AI写脚本 页按账号查看", onClick: () => openScriptsOf(row) }, String(row.rewrite_count) + " 条") : "-"),
react.createElement("div", { key: "op", style: C("center") }, react.createElement("div", { style: { display: "inline-flex", gap: 6, alignItems: "center" } },
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnIcon, title: "编辑账号信息(账号名称不可修改)", onClick: () => openEdit(row) }, react.createElement(IconEdit, { size: 14 })),
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnIcon, title: "AI创作(原创选题:基于该账号设定创作全新脚本,完成后入库「AI写脚本 → 原创选题创作」)", onClick: () => openCreate(row) }, "创作"))));
return cells;
};
const cols = [
{ t: "关注", a: "center", w: 58 }, { t: "类型", a: "left", w: 64 }, { t: "账号名", a: "left", w: 150 }, { t: "赛道", a: "left", w: 92 }, { t: "AI制作", a: "left", w: 74 }, { t: "简介", a: "left", w: 220 }, { t: "粉丝", a: "right", w: 92 }, { t: "获赞", a: "right", w: 96 }, { t: "作品数", a: "right", w: 84 }, { t: "视频数", a: "right", w: 84 }, { t: "AI脚本", a: "center", w: 92 }, { t: "操作", a: "center", w: 150 }
];
if (batchOn) headCells.push(react.createElement("div", { key: "hb", style: HST }, react.createElement("input", { type: "checkbox", checked: pageAllSelected, title: "全选本页", onChange: () => toggleSelectAll() })));
cols.forEach((cc, ci) => { if (!cc.gap) headCells.push(react.createElement("div", { key: "h" + ci, style: { ...HST, textAlign: cc.a } }, cc.t)); else headCells.push(react.createElement("div", { key: "hg" + ci })); });
tableBody = react.createElement("div", { style: { display: "flex", flexDirection: "column", minWidth: 0 } },
react.createElement("div", { style: { display: "grid", gridTemplateColumns: template, alignItems: "center", background: "var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))", position: "sticky", top: 0, zIndex: 2 } }, headCells),
items.map((row) => react.createElement("div", { key: row.id, style: { display: "grid", gridTemplateColumns: template, alignItems: "center" } }, rowCells(row))));
}
return react.createElement("main", { className: cssMap.page, "aria-label": "抖音账号" },
react.createElement("div", { className: cssMap.body },
toolbar,
filterRow,
react.createElement("div", { className: cssMap.tableWrap }, tableBody),
react.createElement("div", { className: cssMap.pager },
react.createElement("div", { style: { display: "inline-flex", alignItems: "center", gap: 8, marginRight: 12 } },
!batchOn
? react.createElement("button", { type: "button", className: cssMap.btn, title: "批量操作:勾选多个账号后删除(连坐清理其视频/解析/设定/AI脚本)", onClick: () => { setBatchOn(true); setSelected({}); } }, react.createElement(IconCheckSquare, { size: 16 }))
: react.createElement(react.Fragment, null,
react.createElement("span", { className: cssMap.pagerInfo }, "已选 " + selCount),
react.createElement("button", { type: "button", className: cssMap.btn, disabled: selCount === 0, style: selCount > 0 ? { borderColor: "#e5484d", color: "#e5484d" } : {}, onClick: submitBatchDelete }, "删除所选"),
react.createElement("button", { type: "button", className: cssMap.btn, onClick: exitBatch }, "取消")),
),
react.createElement("span", { className: cssMap.pagerTotal }, `共 ${total} 条`),
react.createElement("span", { className: cssMap.pagerInfo }, `第 ${page} / ${totalPages} 页`),
react.createElement("button", { type: "button", className: cssMap.btn, disabled: page <= 1, onClick: () => load(page - 1) }, "上一页"),
react.createElement("button", { type: "button", className: cssMap.btn, disabled: page >= totalPages, onClick: () => load(page + 1) }, "下一页"))),
createRow && react.createElement("div", { className: cssMap.mask, onClick: () => setCreateRow(null) },
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
react.createElement("div", { className: cssMap.modalTitle }, "AI创作 · " + createRow.account_name),
react.createElement("div", { className: cssMap.modalText }, "原创选题创作(无原视频):AI 将按「短视频工作台」技能完整流程(S1-S11)基于账号设定与下列创作需求创作全新脚本并自动入库。"),
react.createElement("textarea", { className: cssMap.textarea, rows: 6, style: { width: "100%", boxSizing: "border-box", marginBottom: "12px" }, placeholder: "创作需求(可选):创意 / 创新爆点 / 痛点爽点 / 叙事结构 / 时长等,回车换行。空着则交由 AI 按人设自主决策", value: createText, onChange: (e) => setCreateText(e.target.value) }),
react.createElement("div", { className: cssMap.modalBtns },
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setCreateRow(null) }, "取消"),
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, disabled: creating, onClick: submitCreate }, creating ? "提交中…" : "提交创作")),
)),
refreshRow && react.createElement("div", { className: cssMap.mask, onClick: () => setRefreshRow(null) },
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
react.createElement("div", { className: cssMap.modalTitle }, "确认刷新账号数据"),
react.createElement("div", { className: cssMap.modalText }, `将调用 AI 会话(抖音账号分析技能)通过抖音网页重新获取「${refreshRow.account_name}」的账号信息与视频列表,并更新到数据库。确认刷新?`),
react.createElement("div", { className: cssMap.modalBtns },
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setRefreshRow(null) }, "取消"),
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: submitRefresh }, "确认刷新")))),
importDirOpen && react.createElement("div", { className: cssMap.mask, onClick: () => setImportDirOpen(false) },
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
react.createElement("div", { className: cssMap.modalTitle }, "导入外部账号数据"),
react.createElement("div", { className: cssMap.modalText }, "方式一:填写抖音账号信息,AI 通过抖音网页获取账号信息与视频列表(写入数据库并保存到本地)。"),
react.createElement("input", {
className: cssMap.input,
style: { width: "100%", boxSizing: "border-box", marginBottom: "8px" },
placeholder: "抖音账号名称(如:王微斯)",
value: importAccName,
onChange: (e) => setImportAccName(e.target.value),
}),
react.createElement("input", {
className: cssMap.input,
style: { width: "100%", boxSizing: "border-box", marginBottom: "8px" },
placeholder: "抖音号(如:1919714646)",
value: importAccDid,
onChange: (e) => setImportAccDid(e.target.value),
}),
react.createElement("input", {
className: cssMap.input,
style: { width: "100%", boxSizing: "border-box", marginBottom: "12px" },
placeholder: "抖音账号地址(如:https://www.douyin.com/user/xxx)",
value: importAccUrl,
onChange: (e) => setImportAccUrl(e.target.value),
}),
react.createElement("div", { className: cssMap.modalText, style: { margin: "10px 0 8px" } }, "方式二:输入本地数据文件夹绝对路径,AI 直接读取目录分析文件结构,确认后导入数据库。"),
react.createElement("input", {
className: cssMap.input,
style: { width: "100%", boxSizing: "border-box", marginBottom: "12px" },
placeholder: "本地文件夹路径(如 C:\\Users\\Administrator\\Desktop\\账号对标)",
value: importDirInput,
onChange: (e) => setImportDirInput(e.target.value),
onKeyDown: (e) => { if (e.key === "Enter") submitImport(); },
}),
react.createElement("div", { className: cssMap.modalBtns },
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setImportDirOpen(false) }, "取消"),
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: submitImport, disabled: importing }, importing ? "提交中…" : "提交")))),
editRow && editForm && react.createElement("div", { className: cssMap.mask, onClick: () => setEditRow(null) },
react.createElement("div", { className: cssMap.modal + " " + cssMap.modalEdit, onClick: (e) => e.stopPropagation() },
react.createElement("div", { className: cssMap.modalTitle }, "编辑账号:" + editRow.account_name),
react.createElement("div", { className: cssMap.modalBody },
react.createElement("div", { className: cssMap.formGrid },
editField("赛道", "track"),
react.createElement("label", { className: cssMap.formItem, key: "is_ai" },
react.createElement("span", null, "AI制作"),
react.createElement("select", {
className: cssMap.input,
style: { boxSizing: "border-box", width: "100%" },
value: editForm.is_ai ?? "否",
onChange: (e) => setEditForm({ ...editForm, is_ai: e.target.value }),
},
react.createElement("option", { value: "否", key: "n" }, "否"),
react.createElement("option", { value: "是", key: "y" }, "是"))),
editField("定位/内容", "content", { textarea: true, rows: 4, wide: true }),
editField("抖音号", "douyin_id"),
editField("粉丝", "followers"),
editField("获赞", "total_likes"),
editField("作品数", "works_count", { type: "number" }),
editField("IP属地", "ip_location"),
editField("地区", "location"),
editField("年龄", "age", { type: "number" }),
editField("标签", "tags"),
editField("代表作", "masterpiece"),
editField("简介", "bio", { textarea: true, rows: 4, wide: true }),
)),
react.createElement("div", { className: cssMap.modalBtns },
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setEditRow(null) }, "取消"),
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: saveEdit, disabled: saving }, saving ? "保存中…" : "保存")))));
}
//#endregion
//#region plugin entry:注册到 MCN 工作台入口注册表
const inject = [];
function apply() {
window.__mcnEntries = window.__mcnEntries || [];
// 幂等:避免插件热重载重复注册
if (!window.__mcnEntries.some((e) => e.id === "douyin-accounts")) {
window.__mcnEntries.push({
id: "douyin-accounts",
icon: IconUsers,
title: "热门账号",
desc: "签约/外部账号分析",
component: AccountsPage,
});
}
}
//#endregion
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});
+9
View File
@@ -0,0 +1,9 @@
// dsh-plugin-douyin-accounts 宿主端入口
// 本插件是纯客户端 UI 插件:账号管理页面通过 ./client 导出加载,
// 数据复用 dsh-plugin-mcn 服务端注册的 /mcn/api/* 路由。
// 服务端无需注册路由,仅需一个空 apply 以满足 cordis 插件契约。
function apply() {}
const inject = [];
export { apply, inject };
+26
View File
@@ -0,0 +1,26 @@
{
"name": "dsh-plugin-douyin-accounts",
"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"
}
}
}
@@ -0,0 +1,7 @@
# dsh-plugin-douyin-video-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 detail video table row (__mcnNav.open('douyin-video-detail', ...)).
- insert:
- id: douyin-video-detail
name: dsh-plugin-douyin-video-detail
+496
View File
@@ -0,0 +1,496 @@
window.__ModuleLoader__.load({
id: "dsh-plugin-douyin-video-detail",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
let react = require("react");
let react_dom = require("react-dom");
//#region plugin css(暗色适配:嵌套变量 + 无浅色 fallback,见踩坑手册 3.4)
const css = ".dvv_page{position:relative;background:var(--dsw-alias-bg-base);width:100%;min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex:1;display:grid;grid-template-rows:minmax(0,1fr);overflow:hidden}.dvv_body{display:flex;flex-direction:column;height:100%;min-height:0;padding:16px;box-sizing:border-box;gap:12px}.dvv_toolbar{display:flex;align-items:center;gap:10px;min-width:0}.dvv_btn{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 14px;font:var(--dsw-font-s-14);cursor:pointer;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);transition:background var(--ds-transition-duration-fast) var(--ds-ease-in-out)}.dvv_btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dvv_title{font:var(--dsw-font-l-16);font-weight:600;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.dvv_link{color:var(--dsw-alias-accent-fg);cursor:pointer;text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvv_link:hover{text-decoration:underline}.dvv_infoCard{flex:0 0 auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))}.dvv_infoGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px 18px}.dvv_infoItem{display:flex;flex-direction:column;gap:4px;min-width:0}.dvv_infoItemWide{grid-column:1/-1}.dvv_infoLabel{font:var(--dsw-font-s-12);color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvv_infoValue{font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary);word-break:break-word;white-space:pre-wrap;line-height:1.6}.dvv_analysisHead{flex:0 0 auto;font:var(--dsw-font-l-15);font-weight:600;color:var(--dsw-alias-label-primary)}.dvv_analysisWrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));box-sizing:border-box}.dvv_state{padding:28px 16px;text-align:center;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));font:var(--dsw-font-s-14)}.dvv_loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvv_error{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-danger-fg,var(--dsw-alias-error-fg))}.dvv_empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvv_md{font:var(--dsw-font-s-14);line-height:1.75;color:var(--dsw-alias-label-primary);word-break:break-word}.dvv_md h1,.dvv_md h2,.dvv_md h3,.dvv_md h4{margin:18px 0 8px;font-weight:600;line-height:1.4}.dvv_md h1{font-size:20px;margin-top:26px}.dvv_md h2{font-size:17px;margin-top:24px}.dvv_md h3{font-size:15px;margin-top:20px}.dvv_md h4{font-size:14px;margin-top:18px}.dvv_md p{margin:8px 0}.dvv_md ul,.dvv_md ol{margin:8px 0;padding-left:22px}.dvv_md li{margin:2px 0}.dvv_md a{color:var(--dsw-alias-accent-fg);text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvv_md a:hover{text-decoration:underline}.dvv_md strong{font-weight:600}.dvv_md em{font-style:italic}.dvv_md code{font-family:var(--dsw-font-mono,monospace);font-size:.9em;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:4px;padding:1px 5px}.dvv_md pre{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:12px;overflow:auto;margin:10px 0;line-height:1.55}.dvv_md pre code{background:none;border:none;padding:0;display:block;white-space:pre;font-size:13px}.dvv_md blockquote{margin:10px 0;padding:8px 14px;border-left:3px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-base);border-radius:0 8px 8px 0}.dvv_md blockquote p{margin:4px 0}.dvv_md table{border-collapse:collapse;margin:10px 0;width:100%;font:var(--dsw-font-s-13)}.dvv_md th,.dvv_md td{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));padding:7px 10px;text-align:left;vertical-align:top;line-height:1.6}.dvv_md th{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));font-weight:600;white-space:nowrap}.dvv_md th:first-child,.dvv_md td:first-child{width:18%;min-width:96px;max-width:220px;word-break:break-word}.dvv_md hr{border:none;border-top:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));margin:14px 0}.dvv_tabs{display:flex;gap:4px;flex:0 0 auto;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))}.dvv_tab{border:1px solid transparent;border-boLine truncated
const key = "dsh-plugin-douyin-video-detail/VideoDetail.module.css";
if (!document.querySelector("style[data-plugin-css=" + JSON.stringify(key) + "]")) {
const el = document.createElement("style");
el.dataset.plugin = "dsh-plugin-douyin-video-detail";
el.dataset.pluginCss = key;
el.textContent = css;
document.head.appendChild(el);
}
const cssMap = {
"page": "dvv_page",
"body": "dvv_body",
"toolbar": "dvv_toolbar",
"btn": "dvv_btn",
"title": "dvv_title",
"link": "dvv_link",
"infoCard": "dvv_infoCard",
"infoGrid": "dvv_infoGrid",
"infoItem": "dvv_infoItem",
"infoItemWide": "dvv_infoItemWide",
"infoLabel": "dvv_infoLabel",
"infoValue": "dvv_infoValue",
"analysisHead": "dvv_analysisHead",
"analysisWrap": "dvv_analysisWrap",
"tabs": "dvv_tabs",
"tab": "dvv_tab",
"tabActive": "dvv_tabActive",
"tabBody": "dvv_tabBody",
"state": "dvv_state",
"loading": "dvv_loading",
"error": "dvv_error",
"empty": "dvv_empty",
"md": "dvv_md"
};
//#endregion
//#region 极简 markdown 渲染(视频分析内容:无第三方依赖,渲染为 React 元素避免 XSS)
// 行内语法:**粗体** / *斜体* / `行内代码` / [链接](url)(仅允许 http/https/mailto)
const INLINE_RE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
// 文本段:含 \n 时拆为文本 + <br/>(段落内换行在 HTML 中默认折叠为空格,需显式换行)
function textWithBreaks(seg, keyBase) {
if (!seg.includes("\n")) return [seg];
const parts = seg.split("\n");
const out = [];
parts.forEach((p, i) => {
if (p) out.push(p);
if (i < parts.length - 1) out.push(react.createElement("br", { key: keyBase + "-br" + i }));
});
return out;
}
function parseInline(text, keyBase) {
if (!text) return [text];
const nodes = [];
let last = 0, k = 0;
for (const m of text.matchAll(INLINE_RE)) {
if (m.index > last) nodes.push(...textWithBreaks(text.slice(last, m.index), keyBase + "-t" + k));
const tok = m[0];
if (tok.startsWith("**") && tok.endsWith("**") && tok.length > 4) {
nodes.push(react.createElement("strong", { key: keyBase + "-b" + k }, tok.slice(2, -2)));
} else if (tok.startsWith("*") && tok.endsWith("*") && tok.length > 2) {
nodes.push(react.createElement("em", { key: keyBase + "-i" + k }, tok.slice(1, -1)));
} else if (tok.startsWith("`") && tok.endsWith("`") && tok.length > 2) {
nodes.push(react.createElement("code", { key: keyBase + "-c" + k }, tok.slice(1, -1)));
} else if (tok.startsWith("[")) {
const mm = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
const href = mm && /^(https?:|mailto:)/i.test(mm[2]) ? mm[2] : null;
nodes.push(href
? react.createElement("a", { key: keyBase + "-a" + k, href: href, target: "_blank", rel: "noopener noreferrer" }, mm[1])
: (mm ? mm[1] : tok));
} else {
nodes.push(tok);
}
last = m.index + tok.length;
k++;
}
if (last < text.length) nodes.push(...textWithBreaks(text.slice(last), keyBase + "-t" + k));
return nodes;
}
// 表格块:rows 为已去掉分隔行的字符串数组
function mdTable(rows, keyBase) {
const split = (r) => r.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
const head = split(rows[0]);
const body = rows.slice(1).map(split);
return react.createElement("table", { key: keyBase },
react.createElement("thead", null,
react.createElement("tr", null, head.map((h, i) => react.createElement("th", { key: "h" + i }, parseInline(h, keyBase + "-h" + i))))),
react.createElement("tbody", null,
body.map((row, ri) => react.createElement("tr", { key: "r" + ri },
row.map((c, ci) => react.createElement("td", { key: "c" + ci }, parseInline(c, keyBase + "-c" + ri + "-" + ci)))))));
}
// 块级解析:标题/代码块/引用/表格/列表/分隔线/段落
function mdToReact(md) {
if (typeof md !== "string" || !md.trim()) return null;
const lines = md.replace(/\r\n/g, "\n").split("\n");
const out = [];
let i = 0, k = 0;
while (i < lines.length) {
const t = lines[i].trim();
// 围栏代码块
const fence = t.match(/^```(\S*)\s*$/);
if (fence) {
const buf = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) { buf.push(lines[i]); i++; }
i++;
out.push(react.createElement("pre", { key: "md" + k++ },
react.createElement("code", null, buf.join("\n"))));
continue;
}
// 标题
const h = t.match(/^(#{1,4})\s+(.*)$/);
if (h) {
out.push(react.createElement("h" + h[1].length, { key: "md" + k++ }, parseInline(h[2], "h" + k)));
i++;
continue;
}
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) {
out.push(react.createElement("hr", { key: "md" + k++ }));
i++;
continue;
}
// 引用
if (t.startsWith(">")) {
const buf = [];
while (i < lines.length && lines[i].trim().startsWith(">")) { buf.push(lines[i].trim().replace(/^>\s?/, "")); i++; }
out.push(react.createElement("blockquote", { key: "md" + k++ }, mdToReact(buf.join("\n"))));
continue;
}
// 表格:当前行含 | 且下一行为 |---| 分隔行
if (t.includes("|") && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes("-")) {
const sep = lines[i + 1].replace(/\s/g, "");
if (/^\|?(:?-+:?\|)+:?-+:?\|?$/.test(sep)) {
const buf = [t];
i += 2;
while (i < lines.length && lines[i].trim().includes("|")) { buf.push(lines[i].trim()); i++; }
out.push(mdTable(buf, "md" + k++));
continue;
}
}
// 无序列表
const ul = t.match(/^[-*+]\s+(.*)$/);
if (ul) {
const items = [];
while (i < lines.length) {
const mt = lines[i].trim().match(/^[-*+]\s+(.*)$/);
if (mt) { items.push(mt[1]); i++; continue; }
break;
}
out.push(react.createElement("ul", { key: "md" + k++ },
items.map((it, ii) => react.createElement("li", { key: ii }, parseInline(it, "li" + k + "-" + ii)))));
continue;
}
// 有序列表
const ol = t.match(/^\d+[.)]\s+(.*)$/);
if (ol) {
const items = [];
while (i < lines.length) {
const mt = lines[i].trim().match(/^\d+[.)]\s+(.*)$/);
if (mt) { items.push(mt[1]); i++; continue; }
break;
}
out.push(react.createElement("ol", { key: "md" + k++ },
items.map((it, ii) => react.createElement("li", { key: ii }, parseInline(it, "li" + k + "-" + ii)))));
continue;
}
// 空行跳过
if (t === "") { i++; continue; }
// 段落:合并连续普通行(不跨空行)
const para = [t];
i++;
while (i < lines.length) {
const nt = lines[i].trim();
if (nt === "" || /^(#{1,4}\s|```|>|[-*+]\s|\d+[.)]\s|.*\|)/.test(nt) || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(nt)) break;
para.push(nt);
i++;
}
out.push(react.createElement("p", { key: "md" + k++ }, parseInline(para.join("\n"), "p" + k)));
}
return out;
}
//#endregion
//#region 视频详情页
const fmt = (v) => (v == null || v === "") ? "-" : String(v);
// 时长格式化:秒 → 「X分Y秒」(不足 1 分钟显示「X秒」)
const fmtDuration = (v) => {
const s = Number(v);
if (!Number.isFinite(s) || s <= 0) return "-";
const m = Math.floor(s / 60);
const sec = Math.round(s % 60);
return m > 0 ? `${m}分${sec}秒` : `${sec}秒`;
};
//#region 视频解析任务跟踪(V1.0 同步:解析/刷新 + 断点恢复;localStorage 持久化,页面重建后继续盯任务)
// 记录:{ ts, force };ts=提交时刻。轮询 /mcn/api/task/status 的 analyze[](videoId→running)。
// 结束判定:提交后 90s 宽限窗(服务端 60s 防抖)+ 超时上限 35 分钟。
const PARSE_DEADLINE_MS = 35 * 60 * 1000;
const PARSE_START_WINDOW_MS = 90 * 1000;
const taskKeyOf = (id) => "dvv_parse_task_" + id;
const readTaskRec = (id) => {
try { const raw = localStorage.getItem(taskKeyOf(id)); if (!raw) return null; const o = JSON.parse(raw); return o && o.ts ? o : null; } catch (e) { return null; }
};
const writeTaskRec = (id, force) => {
try { localStorage.setItem(taskKeyOf(id), JSON.stringify({ ts: Date.now(), force: !!force })); } catch (e) {}
};
const clearTaskRec = (id) => {
try { localStorage.removeItem(taskKeyOf(id)); } catch (e) {}
};
//#endregion
// 视频拆解(analysis.json dict,12-13 维度)→ markdown 文本(每个维度一节,供 mdToReact 渲染)
const analysisToMarkdown = (obj) => Object.entries(obj || {})
.map(([k, v]) => `## ${k}\n\n${v}`)
.join("\n\n---\n\n");
// 详情页 tab:视频脚本(content)→ 视频拆解(analysis)
const DETAIL_TABS = [
{ id: "script", label: "视频脚本" },
{ id: "deconstruct", label: "视频拆解" },
];
function VideoDetailPage({ params }) {
const videoId = params && params.videoId != null ? Number(params.videoId) : null;
const awemeId = params && params.awemeId != null ? String(params.awemeId) : null;
const accountId = params && params.accountId != null ? Number(params.accountId) : null;
const [video, setVideo] = react.useState(null);
const [analysis, setAnalysis] = react.useState(null);
const [parse, setParse] = react.useState(null);
const [activeTab, setActiveTab] = react.useState("script");
const [loading, setLoading] = react.useState(videoId != null || awemeId != null);
const [error, setError] = react.useState(null);
// V1.0 同步:解析任务状态 idle|submitting|running + 提示条 + 手动重载计数
const [parseState, setParseState] = react.useState("idle");
const [info, setInfo] = react.useState(null);
const [reloadTick, setReloadTick] = react.useState(0);
const parseVideoId = videoId; // 解析任务只能按库内视频发起(aweme 直链无行不提供解析)
react.useEffect(() => {
if (videoId == null && awemeId == null) return;
let alive = true;
setLoading(true);
setError(null);
setVideo(null);
setAnalysis(null);
setParse(null);
setActiveTab("script");
const qs = new URLSearchParams();
if (awemeId) qs.set("awemeId", awemeId);
else qs.set("videoId", String(videoId));
fetch("/mcn/api/video/detail?" + qs.toString())
.then((r) => r.json())
.then((d) => {
if (!d.ok) throw new Error(d.error || "加载失败");
if (!alive) return;
setVideo(d.video || null);
setAnalysis(d.analysis || null);
setParse(d.parse || null);
})
.catch((e) => { if (alive) setError(e.message); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoId, awemeId, reloadTick]);
// V1.0 同步:解析任务轮询(5s,断点恢复:localStorage 有记录 → 页面重建后继续盯)。
// 初始态恢复:挂载后立即检查一次(record 存在且未超时 → running)。
react.useEffect(() => {
if (parseVideoId == null) return;
let finishedOnce = false;
const check = () => {
const rec = readTaskRec(parseVideoId);
fetch("/mcn/api/task/status")
.then((r) => r.json())
.then((d) => {
if (!d.ok) return;
const arr = Array.isArray(d.analyze) ? d.analyze : [];
const item = arr.find((a) => Number(a.videoId) === parseVideoId);
const serverRunning = !!(item && item.status === "running");
if (rec) {
const age = Date.now() - rec.ts;
if (age > PARSE_DEADLINE_MS) {
clearTaskRec(parseVideoId);
setParseState("idle");
setInfo("解析任务超过 35 分钟未完成(可能已中断),可重新发起");
return;
}
if (serverRunning || age < PARSE_START_WINDOW_MS) {
setParseState((prev) => (prev === "submitting" ? "running" : prev === "running" ? "running" : "running"));
return;
}
// 记录存在、服务端空闲、宽限窗已过 → 任务结束
clearTaskRec(parseVideoId);
setParseState("idle");
if (!finishedOnce) {
finishedOnce = true;
setInfo("解析任务已结束,正在刷新数据…");
setTimeout(() => setReloadTick((x) => x + 1), 300);
}
} else if (serverRunning) {
setParseState("running");
} else if (parseState === "submitting") {
// 提交后宽限期内服务端尚无条目:保持 waiting,交给 rec 分支处理
}
})
.catch(() => {});
};
// 挂载恢复 + 周期轮询
check();
const timer = setInterval(check, 5000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parseVideoId]);
// 提示条自动消隐
react.useEffect(() => {
if (!info) return;
const timer = setTimeout(() => setInfo(null), 6000);
return () => clearTimeout(timer);
}, [info]);
// 提交解析/刷新(V1.0 同步:force=覆盖重解析;服务端 60s 防抖 + 8 并发上限)
const submitParse = (force) => {
if (parseVideoId == null) return;
const tip = force
? "该视频已有解析结果,「刷新」将重新发起 MCP 解析并覆盖(约 3-5 分钟)。确认继续?"
: "对该视频发起 AI 解析(MCP 内容解析,约 3-5 分钟,产出「视频脚本」+「视频拆解」)。确认继续?";
if (!window.confirm(tip)) return;
setParseState("submitting");
fetch("/mcn/api/video/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ videoId: parseVideoId }),
})
.then((r) => r.json())
.then((d) => {
if (d.ok) {
writeTaskRec(parseVideoId, force);
setParseState("running");
setInfo("已提交解析任务(约 3-5 分钟),完成后自动刷新。可在「MCN任务会话」查看执行过程");
} else {
setParseState("idle");
setInfo(d.error || "提交失败");
}
})
.catch((e) => { setParseState("idle"); setInfo("提交失败: " + e.message); });
};
// 无参数(从工作台直接打开):提示从账号详情进入
if (videoId == null && awemeId == null) {
return react.createElement("main", { className: cssMap.page, "aria-label": "视频详情" },
react.createElement("div", { className: cssMap.body },
react.createElement("div", { className: cssMap.toolbar },
react.createElement("button", { type: "button", className: cssMap.btn, title: "返回账号列表", onClick: () => window.__mcnNav?.open("douyin-accounts") }, "←")),
react.createElement("div", { className: cssMap.empty }, "请从「账号详情」的视频列表点击视频ID进入")));
}
const infoItem = (label, value, wide) => {
const cls = cssMap.infoItem + (wide ? " " + cssMap.infoItemWide : "");
return react.createElement("div", { className: cls, key: label },
react.createElement("div", { className: cssMap.infoLabel }, label),
react.createElement("div", { className: cssMap.infoValue }, value));
};
const backTitle = video ? (video.video_title || "视频详情") : "视频详情";
// V1.0 同步:解析/刷新按钮(有解析结果=「刷新」强制覆盖;无=「解析」发起)
const hasParsed = !!(parse && (parse.content || parse.analysis));
const parseBusy = parseState === "submitting" || parseState === "running";
let parseBtn = null;
if (video && parseVideoId != null) {
parseBtn = react.createElement("button", {
type: "button",
className: cssMap.btn,
disabled: parseBusy,
title: parseBusy
? "解析任务执行中(可在 MCN任务会话 查看)"
: (hasParsed
? "重新发起 MCP 解析并覆盖现有脚本/拆解(约 3-5 分钟)"
: "对该视频发起 MCP 内容解析(约 3-5 分钟)"),
onClick: () => submitParse(hasParsed),
}, parseBusy ? (parseState === "submitting" ? "提交中…" : "解析中…") : (hasParsed ? "刷新" : "解析"));
}
// V1.0 对齐:查看原视频(外链)
let viewVideoBtn = null;
if (video && video.video_url) {
viewVideoBtn = react.createElement("a", { className: cssMap.btn, href: video.video_url, target: "_blank", rel: "noopener noreferrer", title: "在抖音打开原视频", style: { marginLeft: "auto" } }, "查看视频 ↗");
}
// 视频信息卡
let infoCard = null;
let analysisArea = null;
if (loading) {
analysisArea = react.createElement("div", { className: cssMap.loading }, "加载中…");
} else if (error) {
analysisArea = react.createElement("div", { className: cssMap.error }, error);
} else if (video) {
const titleVal = video.video_url
? react.createElement("a", {
className: cssMap.link,
href: video.video_url,
target: "_blank",
rel: "noopener noreferrer",
title: "在新页面打开视频",
}, fmt(video.video_title))
: fmt(video.video_title);
infoCard = react.createElement("div", { className: cssMap.infoCard },
react.createElement("div", { className: cssMap.infoGrid },
infoItem("账号", video.account_id ? react.createElement("a", { className: cssMap.link, style: { cursor: "pointer" }, title: "查看账号详情", onClick: () => { try { window.__mcnNav && window.__mcnNav.open("douyin-account-detail", { accountId: video.account_id, accountName: video.account_name }); } catch (e) {} } }, fmt(video.account_name)) : fmt(video.account_name)),
infoItem("视频ID", fmt(video.aweme_id)),
infoItem("时长", fmtDuration(video.duration)),
infoItem("发布时间", fmt(video.publish_time)),
infoItem("点赞", fmt(video.like_display || video.like_count)),
infoItem("评论", fmt(video.comment_count)),
infoItem("分享", fmt(video.share_count)),
infoItem("收藏", fmt(video.collect_count)),
infoItem("标签", fmt(video.tags), true)));
// 三个 tab 内容:视频脚本(MCP content)→ 视频拆解(MCP analysis)→ 视频分析(拆解分析)
const scriptMd = parse && parse.content;
const deconstructMd = parse && parse.analysis ? analysisToMarkdown(parse.analysis) : null;
const analysisMd = analysis && analysis.content;
let tabBody = null;
if (activeTab === "script") {
tabBody = scriptMd
? react.createElement("div", { className: cssMap.md }, mdToReact(scriptMd))
: react.createElement("div", { className: cssMap.state }, "该视频暂无视频脚本(未做原视频解析,可点右上角「解析」发起)");
} else if (activeTab === "deconstruct") {
tabBody = deconstructMd
? react.createElement("div", { className: cssMap.md }, mdToReact(deconstructMd))
: react.createElement("div", { className: cssMap.state }, "该视频暂无视频拆解(MCP 结构化分析,可点右上角「解析」发起)");
} else {
tabBody = analysisMd
? react.createElement("div", { className: cssMap.md }, mdToReact(analysisMd))
: react.createElement("div", { className: cssMap.state }, "该视频暂无视频分析(完成视频分析后展示)");
}
analysisArea = react.createElement(react.Fragment, null,
react.createElement("div", { className: cssMap.tabs },
DETAIL_TABS.map((t) => react.createElement("button", {
key: t.id,
type: "button",
className: cssMap.tab + (activeTab === t.id ? " " + cssMap.tabActive : ""),
onClick: () => setActiveTab(t.id),
}, t.label))),
react.createElement("div", { className: cssMap.tabBody }, tabBody));
}
return react.createElement("main", { className: cssMap.page, "aria-label": "视频详情" },
react.createElement("div", { className: cssMap.body },
react.createElement("div", { className: cssMap.toolbar },
react.createElement("button", {
type: "button",
className: cssMap.btn,
title: "返回账号详情",
onClick: () => window.__mcnNav?.back(),
}, "←"),
react.createElement("span", { className: cssMap.title }, backTitle),
viewVideoBtn,
parseBtn),
info ? react.createElement("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#8a8f98)" } }, info) : null,
infoCard,
analysisArea));
}
//#endregion
//#region plugin entry:注册到 MCN 工作台入口注册表(hidden:不显示在工作台网格,仅程序化导航打开)
// 统一线性 SVG 图标(Lucide 风格)
const IconVideo = ({ size = 16 }) => react.createElement("svg", {
viewBox: "0 0 24 24",
width: size,
height: size,
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": "true",
}, [react.createElement("path", { key: "p1", d: "m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" }), react.createElement("rect", { key: "p2", x: "2", y: "6", width: "14", height: "12", rx: "2" })]);
const inject = [];
function apply() {
window.__mcnEntries = window.__mcnEntries || [];
// 幂等:避免插件热重载重复注册
if (!window.__mcnEntries.some((e) => e.id === "douyin-video-detail")) {
window.__mcnEntries.push({
id: "douyin-video-detail",
icon: IconVideo,
title: "视频详情",
desc: "视频信息与拆解分析(从账号详情视频列表进入)",
hidden: true,
component: VideoDetailPage,
});
}
}
//#endregion
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});
@@ -0,0 +1,9 @@
// dsh-plugin-douyin-video-detail 宿主端入口
// 纯客户端 UI 插件:详情页通过 ./client 导出加载,
// 数据复用 dsh-plugin-mcn 服务端路由(/mcn/api/video/detail)。
// 服务端无需注册路由,仅需一个空 apply 以满足 cordis 插件契约。
function apply() {}
const inject = [];
export { apply, inject };
+26
View File
@@ -0,0 +1,26 @@
{
"name": "dsh-plugin-douyin-video-detail",
"version": "0.1.0",
"description": "抖音视频详情(视频信息 + 拆解分析 markdown) - 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"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
# dsh-plugin-mcn-schedule
计划任务插件(MCN 工作台功能插件):定时提醒 / 周期任务管理。
## 功能
- 创建任务:一次性(指定时间)或循环(固定间隔,最小 1 分钟)
- 到期触发:页面内 toast 提醒 + 触发记录(历史保留)
- 任务管理:编辑 / 启停 / 立即触发(测试)/ 删除
- 数据持久化:`~/.dsh/mcn-schedule.json`
## 入口
注册到 MCN 工作台入口网格(`window.__mcnEntries`),unshift 置顶显示。
## 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /mcn/schedule/list | 任务列表(含 nextRunAt / 历史) |
| POST | /mcn/schedule/create | 创建 {name, type, at?, intervalMinutes?, prompt?, enabled?} |
| POST | /mcn/schedule/update | 更新 {id, ...} |
| POST | /mcn/schedule/delete | 删除 {id} |
| POST | /mcn/schedule/fire-now | 立即触发 {id}(测试) |
| GET | /mcn/schedule/events?since=ts | 轮询新触发事件 |
## 安装
插件目录放入 profile 的 `node_modules/`(如 `profiles/web/node_modules/dsh-plugin-mcn-schedule/`),
bundle patch(`cordis.patch.yml`)自动注册插件行,重启 DSH 后生效。
+6
View File
@@ -0,0 +1,6 @@
# dsh-plugin-mcn-schedule bundle layer: registers the plugin row.
# The feature page registers itself into the MCN workspace entry registry
# (window.__mcnEntries) and is pinned to the top of the home grid.
- insert:
- id: mcn-schedule
name: dsh-plugin-mcn-schedule
+253
View File
@@ -0,0 +1,253 @@
window.__ModuleLoader__.load({
id: "dsh-plugin-mcn-schedule",
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");
let react_jsx_runtime = require("react/jsx-runtime");
//#region plugin css
const css = ".mcnSched_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;grid-template-rows:minmax(0,1fr);display:grid;overflow:hidden}.mcnSched_body{display:flex;flex-direction:column;height:100%;min-height:0;padding:16px;box-sizing:border-box;gap:12px}.mcnSched_toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.mcnSched_title{font-size:16px;font-weight:600;margin:0}.mcnSched_spacer{flex:1}.mcnSched_input{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 10px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);min-width:200px;box-sizing:border-box}.mcnSched_select{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 8px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary)}.mcnSched_textarea{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 10px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);resize:vertical;box-sizing:border-box;width:100%;min-height:64px}.mcnSched_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);white-space:nowrap}.mcnSched_btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.mcnSched_btnPrimary{background:var(--mcn-brand,#4176e6);color:#fff;border-color:transparent}.mcnSched_btnPrimary:hover{background:var(--mcn-brand-hover,#3359c9)}.mcnSched_btnSm{padding:2px 10px;font-size:12px;line-height:1.8}.mcnSched_tableWrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px}.mcnSched_table{width:100%;border-collapse:collapse;font:var(--dsw-font-s-14);min-width:860px}.mcnSched_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}.mcnSched_table td{padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));vertical-align:middle}.mcnSched_table tr:hover{background:var(--dsw-alias-interactive-bg-hover)}.mcnSched_cellTrack{white-space:nowrap}.mcnSched_cellContent{color:var(--dsw-alias-label-secondary);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mcnSched_badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;line-height:18px;white-space:nowrap}.mcnSched_badgeOn{background:rgba(46,190,140,.18);color:var(--dsw-alias-label-primary)}.mcnSched_badgeOff{background:rgba(236,19,19,.14);color:var(--dsw-alias-label-primary)}.mcnSched_badgeAt{background:rgba(65,118,230,.15);color:var(--dsw-alias-label-primary)}.mcnSched_badgeEvery{background:rgba(255,122,69,.15);color:var(--dsw-alias-label-primary)}.mcnSched_badgeAgent{background:rgba(139,92,246,.16);color:var(--dsw-alias-label-primary)}.mcnSched_loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-14)}.mcnSched_empty{padding:40px 20px;text-align:center;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-14);line-height:1.8}.mcnSched_mask{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:flex-start;justify-content:center;padding-top:24vh;z-index:100}.mcnSched_modal{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border-radius:12px;padding:20px;min-width:380px;max-width:460px;box-shadow:0 8px 30px rgba(0,0,0,.2)}.mcnSched_modalTitle{font-size:16px;font-weight:600;margin-bottom:12px}.mcnSched_modalText{font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary);margin-bottom:16px;line-height:1.6}.mcnSched_modalBtns{display:flex;justify-content:flex-end;gap:8px}.mcnSched_formGrid{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}.mcnSched_formItem{display:flex;flex-direction:column;gap:4px;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary)}.mcnSched_formItemWide{grid-column:1/-1}.mcnSched_toast{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:120;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border:1px solid var(--mcn-brand,#4176e6);border-radius:10px;padding:8px 16px;font:var(--dsw-font-s-14);box-shadow:0 8px 30px rgba(0,0,0,.25);max-width:70%;color:var(--dsw-alias-label-primary)}";
const key = "dsh-plugin-mcn-schedule/Schedule.module.css";
if (!document.querySelector("style[data-plugin-css=" + JSON.stringify(key) + "]")) {
const el = document.createElement("style");
el.dataset.plugin = "dsh-plugin-mcn-schedule";
el.dataset.pluginCss = key;
el.textContent = css;
document.head.appendChild(el);
}
const cssMap = {
"page": "mcnSched_page",
"body": "mcnSched_body",
"toolbar": "mcnSched_toolbar",
"title": "mcnSched_title",
"spacer": "mcnSched_spacer",
"input": "mcnSched_input",
"select": "mcnSched_select",
"textarea": "mcnSched_textarea",
"btn": "mcnSched_btn",
"btnPrimary": "mcnSched_btnPrimary",
"btnSm": "mcnSched_btnSm",
"tableWrap": "mcnSched_tableWrap",
"table": "mcnSched_table",
"cellTrack": "mcnSched_cellTrack",
"cellContent": "mcnSched_cellContent",
"badge": "mcnSched_badge",
"badgeOn": "mcnSched_badgeOn",
"badgeOff": "mcnSched_badgeOff",
"badgeAt": "mcnSched_badgeAt",
"badgeEvery": "mcnSched_badgeEvery",
"badgeAgent": "mcnSched_badgeAgent",
"loading": "mcnSched_loading",
"empty": "mcnSched_empty",
"mask": "mcnSched_mask",
"modal": "mcnSched_modal",
"modalTitle": "mcnSched_modalTitle",
"modalText": "mcnSched_modalText",
"modalBtns": "mcnSched_modalBtns",
"formGrid": "mcnSched_formGrid",
"formItem": "mcnSched_formItem",
"formItemWide": "mcnSched_formItemWide",
"toast": "mcnSched_toast"
};
//#endregion
//#region 图标
function IconClock({ size = 22 }) {
return (0, react_jsx_runtime.jsx)("svg", {
viewBox: "0 0 24 24",
width: size,
height: size,
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": "true",
style: { color: "var(--mcn-brand,#4176e6)" },
children: [(0, react_jsx_runtime.jsx)("circle", { cx: "12", cy: "12", r: "10" }), (0, react_jsx_runtime.jsx)("polyline", { points: "12 6 12 12 16 14" })]
});
}
//#endregion
//#region 计划任务页面
function SchedulePage() {
const [tasks, setTasks] = (0, react.useState)([]);
const [events, setEvents] = (0, react.useState)([]);
const [loading, setLoading] = (0, react.useState)(true);
const [modalOpen, setModalOpen] = (0, react.useState)(false);
const [editing, setEditing] = (0, react.useState)(null);
const [toast, setToast] = (0, react.useState)(null);
const lastSeenRef = (0, react.useRef)(Date.now());
const toastTimer = (0, react.useRef)(null);
const fmtTime = (ts) => {
if (ts == null) return "-";
const d = new Date(ts);
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
};
const showToast = (msg) => {
setToast(msg);
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 6000);
};
const load = () => {
fetch("/mcn/schedule/list")
.then((r) => r.json())
.then((d) => { if (d.ok) { setTasks(d.tasks || []); } })
.catch(() => {})
.finally(() => setLoading(false));
};
const pollEvents = () => {
const since = lastSeenRef.current;
fetch("/mcn/schedule/events?since=" + since)
.then((r) => r.json())
.then((d) => {
if (!d.ok || !d.events || d.events.length === 0) return;
const last = Math.max(...d.events.map((e) => new Date(e.at).getTime()));
lastSeenRef.current = Math.max(lastSeenRef.current, last);
setEvents((prev) => [...d.events, ...prev].slice(0, 20));
for (const ev of d.events) {
showToast(`⏰ 计划任务「${ev.name}」${ev.prompt ? ":" + ev.prompt : "已触发"}`);
}
load();
})
.catch(() => {});
};
(0, react.useEffect)(() => {
load();
const t1 = setInterval(pollEvents, 12000);
const t2 = setInterval(load, 30000);
return () => { clearInterval(t1); clearInterval(t2); if (toastTimer.current) clearTimeout(toastTimer.current); };
}, []);
const openCreate = () => { setEditing(null); setModalOpen(true); };
const openEdit = (t) => { setEditing(t); setModalOpen(true); };
const toggleEnabled = (t) => {
fetch("/mcn/schedule/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: t.id, enabled: !t.enabled }),
}).then((r) => r.json()).then((d) => { if (d.ok) { load(); showToast(!t.enabled ? "已启用" : "已停用"); } }).catch(() => {});
};
const fireNow = (t) => {
fetch("/mcn/schedule/fire-now", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: t.id }),
}).then((r) => r.json()).then((d) => { if (d.ok) { showToast("已触发测试"); setTimeout(load, 500); } }).catch(() => {});
};
const remove = (t) => {
if (!window.confirm(`确认删除任务「${t.name}」?`)) return;
fetch("/mcn/schedule/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: t.id }),
}).then((r) => r.json()).then((d) => { if (d.ok) { load(); showToast("已删除"); } }).catch(() => {});
};
const submit = (form) => {
const url = editing ? "/mcn/schedule/update" : "/mcn/schedule/create";
const body = { name: form.name, prompt: form.prompt, enabled: form.enabled, type: form.type, action: form.action || "remind" };
if (form.type === "at") body.at = form.at;
else body.intervalMinutes = Number(form.intervalMinutes);
if (editing) body.id = editing.id;
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((r) => r.json()).then((d) => {
if (d.ok) { setModalOpen(false); load(); showToast(editing ? "已保存" : "已创建"); }
else { showToast(d.error || "操作失败"); }
}).catch(() => showToast("请求失败"));
};
return (0, react_jsx_runtime.jsxs)("div", {
className: cssMap.page,
children: [(0, react_jsx_runtime.jsxs)("div", {
className: cssMap.body,
children: [(0, react_jsx_runtime.jsxs)("div", {
className: cssMap.toolbar,
children: [(0, react_jsx_runtime.jsx)("span", { className: cssMap.title, children: "计划任务" }), (0, react_jsx_runtime.jsx)("span", { className: cssMap.spacer }), (0, react_jsx_runtime.jsx)("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: openCreate, children: "+ 新建任务" })]
}), events.length > 0 && (0, react_jsx_runtime.jsx)("div", {
className: cssMap.cellTrack,
style: { font: "var(--dsw-font-s-13)", color: "var(--dsw-alias-label-secondary)" },
children: "最近触发:" + events.slice(0, 3).map((e) => `${e.name}(${fmtTime(new Date(e.at).getTime())})`).join("、")
}), loading ? (0, react_jsx_runtime.jsx)("div", { className: cssMap.loading, children: "加载中…" }) : tasks.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: cssMap.empty, children: "暂无计划任务,点击右上角「+ 新建任务」创建" }) : (0, react_jsx_runtime.jsx)("div", {
className: cssMap.tableWrap,
children: (0, react_jsx_runtime.jsx)("table", {
className: cssMap.table,
children: [(0, react_jsx_runtime.jsx)("thead", {
children: (0, react_jsx_runtime.jsx)("tr", {
children: ["任务名称", "类型", "状态", "下次执行", "上次执行", "提醒内容", "操作"].map((h) => (0, react_jsx_runtime.jsx)("th", { children: h }))
})
}), (0, react_jsx_runtime.jsx)("tbody", {
children: tasks.map((t) => (0, react_jsx_runtime.jsx)("tr", {
children: [(0, react_jsx_runtime.jsx)("td", { className: cssMap.cellTrack, children: t.name }), (0, react_jsx_runtime.jsx)("td", { children: [(0, react_jsx_runtime.jsx)("span", { className: cssMap.badge + " " + (t.type === "at" ? cssMap.badgeAt : cssMap.badgeEvery), children: t.type === "at" ? "一次性" : "循环" }), t.action === "agent" && (0, react_jsx_runtime.jsx)("span", { className: cssMap.badge + " " + cssMap.badgeAgent, children: "AI执行" })] }), (0, react_jsx_runtime.jsx)("td", { children: (0, react_jsx_runtime.jsx)("span", { className: cssMap.badge + " " + (t.enabled ? cssMap.badgeOn : cssMap.badgeOff), children: t.enabled ? "启用" : "停用" }) }), (0, react_jsx_runtime.jsx)("td", { className: cssMap.cellTrack, children: t.type === "at" ? fmtTime(t.nextRunAt) + (t.firedAt ? "(已触发)" : "") : fmtTime(t.nextRunAt) }), (0, react_jsx_runtime.jsx)("td", { className: cssMap.cellTrack, children: fmtTime(t.lastFiredAt) }), (0, react_jsx_runtime.jsx)("td", { className: cssMap.cellContent, children: t.prompt || "-" }), (0, react_jsx_runtime.jsx)("td", {
children: [(0, react_jsx_runtime.jsx)("button", { type: "button", className: cssMap.btn + " " + cssMap.btnSm, onClick: () => openEdit(t), children: "编辑" }), " ", (0, react_jsx_runtime.jsx)("button", { type: "button", className: cssMap.btn + " " + cssMap.btnSm, onClick: () => toggleEnabled(t), children: t.enabled ? "停用" : "启用" }), " ", (0, react_jsx_runtime.jsx)("button", { type: "button", className: cssMap.btn + " " + cssMap.btnSm, onClick: () => fireNow(t), children: "触发" }), " ", (0, react_jsx_runtime.jsx)("button", { type: "button", className: cssMap.btn + " " + cssMap.btnSm, onClick: () => remove(t), children: "删除" })]
})]
}, t.id))
})]
})
})]
}), modalOpen && (0, react_jsx_runtime.jsxs)("div", {
className: cssMap.mask,
onClick: () => setModalOpen(false),
children: [(0, react_jsx_runtime.jsxs)("div", {
className: cssMap.modal,
onClick: (e) => e.stopPropagation(),
children: [(0, react_jsx_runtime.jsx)("div", { className: cssMap.modalTitle, children: editing ? `编辑任务「${editing.name}」` : "新建计划任务" }), (0, react_jsx_runtime.jsx)(ScheduleForm, { initial: editing, onSubmit: submit, onCancel: () => setModalOpen(false) })]
})]
}), toast && (0, react_jsx_runtime.jsx)("div", { className: cssMap.toast, children: toast })]
});
}
function ScheduleForm({ initial, onSubmit, onCancel }) {
const [name, setName] = (0, react.useState)(initial?.name || "");
const [type, setType] = (0, react.useState)(initial?.type || "at");
const [at, setAt] = (0, react.useState)(initial?.at || "");
const [intervalMinutes, setIntervalMinutes] = (0, react.useState)(String(initial?.intervalMinutes || 10));
const [prompt, setPrompt] = (0, react.useState)(initial?.prompt || "");
const [action, setAction] = (0, react.useState)(initial?.action || "remind");
const [enabled, setEnabled] = (0, react.useState)(initial?.enabled !== false);
const [err, setErr] = (0, react.useState)("");
const submit = () => {
if (!name.trim()) { setErr("请填写任务名称"); return; }
if (type === "at" && !at) { setErr("请选择执行时间"); return; }
if (type === "every" && (!Number(intervalMinutes) || Number(intervalMinutes) < 1)) { setErr("周期间隔不能小于 1 分钟"); return; }
onSubmit({ name, type, at, intervalMinutes, prompt, action, enabled });
};
return (0, react_jsx_runtime.jsxs)("div", {
children: [(0, react_jsx_runtime.jsxs)("div", {
className: cssMap.formGrid,
children: [(0, react_jsx_runtime.jsx)("label", { className: cssMap.formItemWide, children: [(0, react_jsx_runtime.jsx)("span", { children: "任务名称" }), (0, react_jsx_runtime.jsx)("input", { className: cssMap.input, style: { width: "100%" }, value: name, onChange: (e) => setName(e.target.value), placeholder: "如:每日检查日榜" })] }), (0, react_jsx_runtime.jsx)("label", { className: cssMap.formItem, children: [(0, react_jsx_runtime.jsx)("span", { children: "任务类型" }), (0, react_jsx_runtime.jsx)("select", { className: cssMap.select, value: type, onChange: (e) => setType(e.target.value), children: [(0, react_jsx_runtime.jsx)("option", { value: "at", children: "一次性(指定时间)" }), (0, react_jsx_runtime.jsx)("option", { value: "every", children: "循环(固定间隔)" })] })] }), (0, react_jsx_runtime.jsx)("label", { className: cssMap.formItem, children: [(0, react_jsx_runtime.jsx)("span", { children: "动作" }), (0, react_jsx_runtime.jsx)("select", { className: cssMap.select, value: action, onChange: (e) => setAction(e.target.value), children: [(0, react_jsx_runtime.jsx)("option", { value: "remind", children: "仅页面提醒" }), (0, react_jsx_runtime.jsx)("option", { value: "agent", children: "触发 AI 执行" })] })] }), type === "every" ? (0, react_jsx_runtime.jsx)("label", { className: cssMap.formItem, children: [(0, react_jsx_runtime.jsx)("span", { children: "间隔(分钟)" }), (0, react_jsx_runtime.jsx)("input", { className: cssMap.input, type: "number", min: 1, value: intervalMinutes, onChange: (e) => setIntervalMinutes(e.target.value) })] }) : (0, react_jsx_runtime.jsx)("label", { className: cssMap.formItem, children: [(0, react_jsx_runtime.jsx)("span", { children: "执行时间" }), (0, react_jsx_runtime.jsx)("input", { className: cssMap.input, type: "datetime-local", value: at, onChange: (e) => setAt(e.target.value) })] }), (0, react_jsx_runtime.jsx)("label", { className: cssMap.formItemWide, children: [(0, react_jsx_runtime.jsx)("span", { children: action === "agent" ? "任务指令(AI 将执行的内容)" : "提醒内容(可选)" }), (0, react_jsx_runtime.jsx)("textarea", { className: cssMap.textarea, value: prompt, onChange: (e) => setPrompt(e.target.value), placeholder: action === "agent" ? "如:分析今日抖音日榜数据,生成简报保存到工作区" : "到点后提醒的内容,如:检查今日日榜数据" })] }), (0, react_jsx_runtime.jsx)("label", { className: cssMap.formItemWide, children: [(0, react_jsx_runtime.jsx)("span", { children: "状态" }), (0, react_jsx_runtime.jsx)("select", { className: cssMap.select, value: enabled ? "1" : "0", onChange: (e) => setEnabled(e.target.value === "1"), children: [(0, react_jsx_runtime.jsx)("option", { value: "1", children: "启用" }), (0, react_jsx_runtime.jsx)("option", { value: "0", children: "停用" })] })] }), err && (0, react_jsx_runtime.jsx)("div", { className: cssMap.formItemWide, style: { color: "#ff6b6b" }, children: err })]
}), (0, react_jsx_runtime.jsxs)("div", {
className: cssMap.modalBtns,
children: [(0, react_jsx_runtime.jsx)("button", { type: "button", className: cssMap.btn, onClick: onCancel, children: "取消" }), (0, react_jsx_runtime.jsx)("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: submit, children: initial ? "保存" : "创建" })]
})]
});
}
//#endregion
//#region plugin entry
const inject = [];
function apply() {
window.__mcnEntries = window.__mcnEntries || [];
// 幂等:避免插件热重载重复注册
if (!window.__mcnEntries.some((e) => e.id === "mcn-schedule")) {
// unshift 置顶:入口放到 MCN 工作台上方
window.__mcnEntries.unshift({
id: "mcn-schedule",
icon: IconClock,
title: "计划任务",
desc: "定时提醒 / 周期任务",
component: SchedulePage,
});
}
}
//#endregion
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});
+483
View File
@@ -0,0 +1,483 @@
// dsh-plugin-mcn-schedule 宿主端:计划任务(定时提醒/周期任务)
// - 数据持久化:~/.dsh/mcn-schedule.json
// - 注册 /mcn/schedule/* HTTP 路由供前端管理
// - 内置调度器:每 10 秒检查到期任务,触发事件供前端轮询提醒
// - 支持动作:remind(页面提醒)/ agent(唤醒 AI 会话执行任务)
import { homedir } from "node:os";
import { join } from "node:path";
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { createUserMessage } from "@deepseek-ai/dsh-llm";
const DATA_PATH = join(homedir(), ".dsh", "mcn-schedule.json");
const TICK_MS = 10 * 1000; // 调度检查周期
const MAX_EVENTS = 50; // 事件保留上限
const MAX_HISTORY = 20; // 每个任务触发历史保留上限
const MIN_INTERVAL_MINUTES = 1; // 周期任务最小间隔(分钟)
const TASK_AGENT_PREFIX = "dsh_schedule-"; // 任务执行会话前缀
const TASK_WORKSPACE_TITLE = "计划任务"; // 任务会话分组工作区标题
const AGENT_PROVIDER = "deepseek-official";
const AGENT_MODEL = "deepseek-v4-flash";
let taskWorkspacePath = null; // 任务会话工作区(apply 时解析)
let taskWorkspace = null; // 「计划任务」工作区对象(含 attachSession)
//#region 工作区解析(与 dsh-plugin-mcn 一致:workspace.json 深度优先取根工作区)
function resolveWorkspaceRoot() {
try {
const raw = readFileSync(join(homedir(), ".dsh", "storages", "workspace.json"), "utf8");
const data = JSON.parse(raw);
const workspaces = data?.tables?.workspaces;
if (workspaces) {
const candidates = Object.values(workspaces)
.filter((ws) => typeof ws?.path === "string" && existsSync(ws.path))
.sort((a, b) => String(a.path).split(/[\\/]/).length - String(b.path).split(/[\\/]/).length);
if (candidates.length > 0) return candidates[0].path;
}
} catch (e) { /* fall through */ }
return "D:\\dshworkspace";
}
// 确保「计划任务」工作区存在(目录 + workspaceRegistry 注册),缓存工作区对象
async function ensureTaskWorkspace(ctx) {
const root = resolveWorkspaceRoot();
const dir = join(root, TASK_WORKSPACE_TITLE);
try { mkdirSync(dir, { recursive: true }); } catch (e) { /* ignore */ }
try {
const reg = ctx.get("workspaceRegistry");
if (reg) {
const existing = reg.list().find((w) => w.path === dir || w.title === TASK_WORKSPACE_TITLE);
if (existing) {
taskWorkspace = existing;
} else {
taskWorkspace = await reg.create(dir, TASK_WORKSPACE_TITLE);
if (taskWorkspace.title !== TASK_WORKSPACE_TITLE) {
try { await taskWorkspace.setTitle(TASK_WORKSPACE_TITLE); } catch (e) { /* 更名失败不阻塞 */ }
}
console.log(`[dsh-plugin-mcn-schedule] 已创建工作区「${TASK_WORKSPACE_TITLE}」: ${dir}`);
}
}
} catch (e) {
console.warn(`[dsh-plugin-mcn-schedule] 工作区注册失败: ${e.message}`);
}
taskWorkspacePath = dir;
return dir;
}
// 把会话 attach 到「计划任务」分组(内部按 header cwd 校验)
async function attachTaskSession(ctx, sessionId) {
try {
const reg = ctx.get("workspaceRegistry");
const ws = taskWorkspace || (reg && reg.list().find((w) => w.path === taskWorkspacePath || w.title === TASK_WORKSPACE_TITLE));
if (!ws) return;
if (!ws.sessionIds.includes(sessionId)) {
await ws.attachSession(sessionId);
console.log(`[dsh-plugin-mcn-schedule] 会话 ${sessionId} 已归入「计划任务」分组`);
}
} catch (e) {
console.warn(`[dsh-plugin-mcn-schedule] 会话 ${sessionId} 归组失败: ${e.message}`);
}
}
// 启动修复:把磁盘上已有的 dsh_schedule-* 会话重新 attach 到「计划任务」分组
async function repairTaskSessions(ctx) {
try {
const sessionsRoot = join(homedir(), ".dsh", "sessions");
if (!existsSync(sessionsRoot)) return;
const dirs = readdirSync(sessionsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory())
.flatMap((e) => readdirSync(join(sessionsRoot, e.name), { withFileTypes: true })
.filter((s) => s.isDirectory() && s.name.startsWith(TASK_AGENT_PREFIX))
.map((s) => s.name));
for (const sessionId of dirs) {
await attachTaskSession(ctx, sessionId);
}
if (dirs.length > 0) console.log(`[dsh-plugin-mcn-schedule] 计划任务会话分组修复完成(${dirs.length} 个会话)`);
} catch (e) {
console.warn(`[dsh-plugin-mcn-schedule] 会话分组修复失败: ${e.message}`);
}
}
// 任务会话工作区:优先「计划任务」分组,未就绪时回退根工作区
function resolveTaskWorkspace() {
if (taskWorkspacePath) return taskWorkspacePath;
return join(resolveWorkspaceRoot(), TASK_WORKSPACE_TITLE);
}
async function composeDefaultPreset(ctx) {
const presets = ctx.get("agentPresets");
if (!presets) return { setup: () => Promise.resolve() };
let resolvedId;
// 优先 MCN 模式(含 myai MCP 工具);缺失时回退默认 preset
try {
resolvedId = (await presets.resolve("mcn")).id;
} catch (e) {
try {
resolvedId = (await presets.resolve(undefined)).id;
} catch (e2) {
return { setup: () => Promise.resolve() };
}
}
return {
agentPreset: resolvedId,
setup: async (agentCtx) => { await presets.mount(agentCtx, resolvedId); },
};
}
// 获取(或创建)任务执行会话:默认 dsh_schedule-{taskId},可指定 targetSession
async function getOrCreateTaskAgent(ctx, task) {
const sessionId = task.targetSession || TASK_AGENT_PREFIX + task.id;
const existing = ctx.agents.get(sessionId);
if (existing !== undefined) {
return { agent: existing, created: false };
}
const agentOptions = { provider: AGENT_PROVIDER, model: AGENT_MODEL };
const compose = await composeDefaultPreset(ctx);
const cwd = resolveTaskWorkspace();
let handle;
try {
handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
setup: compose.setup,
});
} catch (e) {
handle = await ctx.agents.create({
sessionId,
agentOptions,
meta: { cwd, agentPreset: compose.agentPreset },
setup: compose.setup,
});
}
// 归入「计划任务」分组(cwd 与工作区 path 匹配时成功)
await attachTaskSession(ctx, sessionId);
return { agent: handle.agent };
}
//#endregion
//#region 存储
let store = { tasks: [], events: [] };
let tickCtx = null; // apply 时注入,供调度器唤醒 AI 会话使用
function loadStore() {
try {
if (existsSync(DATA_PATH)) {
const raw = JSON.parse(readFileSync(DATA_PATH, "utf8"));
store = {
tasks: Array.isArray(raw.tasks) ? raw.tasks : [],
events: Array.isArray(raw.events) ? raw.events : [],
};
}
} catch (e) {
console.warn(`[dsh-plugin-mcn-schedule] 数据读取失败,使用空存储: ${e.message}`);
store = { tasks: [], events: [] };
}
}
function saveStore() {
try {
writeFileSync(DATA_PATH, JSON.stringify(store, null, 2), "utf8");
} catch (e) {
console.warn(`[dsh-plugin-mcn-schedule] 数据写入失败: ${e.message}`);
}
}
function nextId() {
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
function computeNextRun(task) {
const now = Date.now();
if (task.type === "at") return task.atTs;
if (task.type === "every") {
const base = task.lastFiredAt || task.createdAt;
return base + task.intervalMinutes * 60 * 1000;
}
return null;
}
//#endregion
//#region 调度器
async function fireTask(ctx, task, now) {
const firedAt = new Date(now).toISOString();
let action = task.action || "remind";
let dispatched = null;
let error = null;
if (action === "agent") {
try {
const text = task.prompt && task.prompt.trim()
? task.prompt.trim()
: `请执行计划任务「${task.name}」。`;
const { agent } = await getOrCreateTaskAgent(ctx, task);
agent.followup(createUserMessage({
content: [{ type: "text", text }],
source: { kind: "user" },
}));
dispatched = { sessionId: task.targetSession || TASK_AGENT_PREFIX + task.id };
console.log(`[dsh-plugin-mcn-schedule] 任务「${task.name}」已投递给 AI 会话 ${dispatched.sessionId}`);
} catch (e) {
error = String(e.message || e).slice(0, 200);
console.warn(`[dsh-plugin-mcn-schedule] 任务「${task.name}」AI 投递失败: ${error}`);
}
}
store.events.push({
id: nextId(),
taskId: task.id,
name: task.name,
prompt: task.prompt || "",
type: task.type,
action,
dispatched,
error,
at: firedAt,
});
if (store.events.length > MAX_EVENTS) store.events = store.events.slice(-MAX_EVENTS);
task.history = task.history || [];
task.history.push({ at: firedAt, type: task.type, action, error: error || undefined });
if (task.history.length > MAX_HISTORY) task.history = task.history.slice(-MAX_HISTORY);
if (task.type === "at") {
task.firedAt = firedAt;
task.enabled = false; // 一次性任务触发后停用
} else {
task.lastFiredAt = now;
}
task.nextRunAt = computeNextRun(task);
saveStore();
console.log(`[dsh-plugin-mcn-schedule] 触发任务「${task.name}」 action=${action}${error ? " error=" + error : ""}`);
}
function tick() {
const now = Date.now();
let changed = false;
for (const task of store.tasks) {
if (!task.enabled) continue;
if (typeof task.nextRunAt !== "number") {
task.nextRunAt = computeNextRun(task);
changed = true;
continue;
}
if (task.nextRunAt <= now) {
fireTask(tickCtx, task, now).catch((e) => {
console.warn(`[dsh-plugin-mcn-schedule] tick 触发异常: ${e.message}`);
});
changed = true;
}
}
if (changed) saveStore();
}
//#endregion
//#region 路由
function apply(ctx) {
tickCtx = ctx;
// 启动时确保「计划任务」工作区存在,并修复存量会话分组(不阻塞路由注册)
ensureTaskWorkspace(ctx)
.then(() => repairTaskSessions(ctx))
.catch((e) => {
console.warn(`[dsh-plugin-mcn-schedule] 工作区初始化失败: ${e.message}`);
});
const server = ctx.get("webServer");
if (!server) {
console.warn("[dsh-plugin-mcn-schedule] webServer 服务不可用,跳过路由注册");
return;
}
const json = (res, status, data) => {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data));
};
const readBody = (req) =>
new Promise((resolve, reject) => {
let buf = "";
req.on("data", (c) => { buf += c; if (buf.length > 1e6) req.destroy(); });
req.on("end", () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch (e) { reject(e); } });
req.on("error", reject);
});
const parseQuery = (url) => {
const q = {};
const i = url.indexOf("?");
if (i >= 0) new URLSearchParams(url.slice(i + 1)).forEach((v, k) => { q[k] = v; });
return q;
};
// 任务列表
server.register({
path: "/mcn/schedule/list",
exact: true,
handler: async (req, res) => {
const now = Date.now();
json(res, 200, {
ok: true,
now,
tasks: store.tasks.map((t) => ({
id: t.id,
name: t.name,
type: t.type,
at: t.at || "",
intervalMinutes: t.intervalMinutes || 0,
prompt: t.prompt || "",
action: t.action || "remind",
targetSession: t.targetSession || "",
enabled: !!t.enabled,
createdAt: t.createdAt,
firedAt: t.firedAt || null,
lastFiredAt: t.lastFiredAt || null,
nextRunAt: t.nextRunAt ?? null,
history: (t.history || []).slice(-10),
})),
});
},
});
// 创建任务:{name, type:"at"|"every", at?, intervalMinutes?, prompt?, enabled?}
server.register({
path: "/mcn/schedule/create",
exact: true,
handler: async (req, res) => {
try {
const b = await readBody(req);
const name = String(b.name || "").trim();
if (!name) return json(res, 400, { ok: false, error: "任务名称不能为空" });
const type = b.type === "every" ? "every" : "at";
let atTs = null;
let intervalMinutes = 0;
if (type === "at") {
const atStr = String(b.at || "").trim();
if (!atStr) return json(res, 400, { ok: false, error: "请选择执行时间" });
atTs = new Date(atStr).getTime();
if (!Number.isFinite(atTs)) return json(res, 400, { ok: false, error: "执行时间格式不正确" });
if (atTs <= Date.now()) return json(res, 400, { ok: false, error: "执行时间必须晚于当前时间" });
} else {
intervalMinutes = Math.round(Number(b.intervalMinutes) || 0);
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
return json(res, 400, { ok: false, error: `周期任务间隔不能小于 ${MIN_INTERVAL_MINUTES} 分钟` });
}
}
const now = Date.now();
const task = {
id: nextId(),
name,
type,
at: type === "at" ? String(b.at || "") : "",
atTs,
intervalMinutes,
prompt: String(b.prompt || "").trim(),
action: b.action === "agent" ? "agent" : "remind",
targetSession: typeof b.targetSession === "string" ? b.targetSession.trim() : "",
enabled: b.enabled !== false,
createdAt: now,
lastFiredAt: null,
firedAt: null,
history: [],
};
task.nextRunAt = computeNextRun(task);
store.tasks.push(task);
saveStore();
json(res, 200, { ok: true, task: { id: task.id } });
} catch (e) {
json(res, 400, { ok: false, error: e.message });
}
},
});
// 更新任务:{id, name?, prompt?, enabled?, at?, intervalMinutes?, type?}
server.register({
path: "/mcn/schedule/update",
exact: true,
handler: async (req, res) => {
try {
const b = await readBody(req);
const task = store.tasks.find((t) => t.id === b.id);
if (!task) return json(res, 404, { ok: false, error: "任务不存在" });
if (typeof b.name === "string" && b.name.trim()) task.name = b.name.trim();
if (typeof b.prompt === "string") task.prompt = b.prompt.trim();
if (typeof b.enabled === "boolean") task.enabled = b.enabled;
if (b.action === "agent" || b.action === "remind") task.action = b.action;
if (typeof b.targetSession === "string") task.targetSession = b.targetSession.trim();
if (b.type === "at" || b.type === "every") {
task.type = b.type;
if (task.type === "at") {
const atStr = String(b.at || "").trim();
if (!atStr) return json(res, 400, { ok: false, error: "请选择执行时间" });
const ts = new Date(atStr).getTime();
if (!Number.isFinite(ts)) return json(res, 400, { ok: false, error: "执行时间格式不正确" });
task.at = atStr;
task.atTs = ts;
task.intervalMinutes = 0;
} else {
const minutes = Math.round(Number(b.intervalMinutes) || 0);
if (minutes < MIN_INTERVAL_MINUTES) {
return json(res, 400, { ok: false, error: `周期任务间隔不能小于 ${MIN_INTERVAL_MINUTES} 分钟` });
}
task.intervalMinutes = minutes;
task.at = "";
task.atTs = null;
task.lastFiredAt = null; // 重新锚定周期
}
}
task.nextRunAt = computeNextRun(task);
saveStore();
json(res, 200, { ok: true });
} catch (e) {
json(res, 400, { ok: false, error: e.message });
}
},
});
// 删除任务:{id}
server.register({
path: "/mcn/schedule/delete",
exact: true,
handler: async (req, res) => {
try {
const b = await readBody(req);
const idx = store.tasks.findIndex((t) => t.id === b.id);
if (idx < 0) return json(res, 404, { ok: false, error: "任务不存在" });
store.tasks.splice(idx, 1);
saveStore();
json(res, 200, { ok: true });
} catch (e) {
json(res, 400, { ok: false, error: e.message });
}
},
});
// 立即触发(测试用):{id}
server.register({
path: "/mcn/schedule/fire-now",
exact: true,
handler: async (req, res) => {
try {
const b = await readBody(req);
const task = store.tasks.find((t) => t.id === b.id);
if (!task) return json(res, 404, { ok: false, error: "任务不存在" });
await fireTask(ctx, task, Date.now());
json(res, 200, { ok: true });
} catch (e) {
json(res, 400, { ok: false, error: e.message });
}
},
});
// 事件轮询:?since=<epochMs> 返回该时间之后的新触发事件
server.register({
path: "/mcn/schedule/events",
exact: true,
handler: async (req, res) => {
const q = parseQuery(req.url);
const since = Number(q.since) || 0;
const fresh = store.events.filter((e) => new Date(e.at).getTime() > since);
json(res, 200, { ok: true, events: fresh });
},
});
// 启动调度器
const timer = setInterval(tick, TICK_MS);
timer.unref?.();
console.log("[dsh-plugin-mcn-schedule] 计划任务插件已启动");
}
const inject = ["webServer", "agents", "workspaceRegistry"];
export { apply, inject };
+27
View File
@@ -0,0 +1,27 @@
{
"name": "dsh-plugin-mcn-schedule",
"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",
"README.md"
],
"dsh": {
"client": {
"platform": "web",
"inject": []
},
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
+84
View File
@@ -0,0 +1,84 @@
# dsh-plugin-mcn
MCN 短视频插件:在 DSH Web 左侧面板添加「MCN短视频」导航按钮,
点击进入抖音账号列表页面(数据来自 MCP 服务,本地 SQLite 缓存)。
参考实现:[deepseek-plugin-store](https://github.com/Ericwong5021/deepseek-plugin-store)
## 功能
- 左侧边栏底部新增「MCN短视频」按钮(图标 + 文字,窄栏模式显示图标)
- 点击进入**抖音账号列表页**(非 iframe,内部 React 页面)
- 账号数据:从 MCP `list_hot_accounts` 获取,**首次自动拉取存入本地 SQLite**(`~/.dsh/mcn-plugin.db`)
- **分页**:每页 20 条,上一页/下一页
- **排序**:默认按序号(id)升序,点击表头可切换排序(序号/账号名/赛道)
- **搜索**:按账号名 / 内容 / 赛道模糊搜索
- **筛选**:按赛道下拉筛选
- **更新**:点击弹窗确认后,从 MCP 重新拉取并覆盖本地缓存
- 页面左上角「← 返回」按钮;URL 路由 `/mcn`
## 架构
```
客户端(React,client.js)
│ fetch /mcn/api/*
宿主端(Node,index.js)
├─ 本地 SQLite(node:sqlite,Node 24 内置,~/.dsh/mcn-plugin.db)
├─ MCP stdio 客户端(spawn npx -y myai-mcp → list_hot_accounts)
└─ HTTP 路由:/mcn/api/accounts|tracks|refresh|status
```
### 数据流
```
首次访问 /mcn 页面
→ GET /mcn/api/accounts → 宿主端查 SQLite
→ 空 → 自动调 MCP list_hot_accounts → 写入 SQLite → 返回
→ 有 → 直接返回缓存
点击「更新」(弹窗确认)
→ POST /mcn/api/refresh → 调 MCP → 覆盖 SQLite → 返回新数量
```
## 安装(本地开发)
```sh
dsh plugin --profile web add file:D:/dshworkspace/plugin_package/dsh-plugin-mcn
```
或通过 patch 直接挂载:
```yaml
# cordis.patch.yml(profile 层)
- insert:
- id: mcn-nav
name: '/绝对路径/dsh-plugin-mcn/lib/index.js'
```
## 结构
```
dsh-plugin-mcn/
├── package.json ← dsh.bundle 声明 + client 入口
├── cordis.patch.yml ← 挂载条目
├── lib/
│ ├── index.js ← 宿主端(SQLite + MCP 拉取 + HTTP 路由)
│ └── client.js ← 客户端 bundle(侧边栏按钮 + 账号列表页)
└── README.md
```
## API
| 路由 | 方法 | 说明 |
|------|------|------|
| `/mcn/api/accounts?page=&pageSize=&search=&track=&sortBy=&order=` | GET | 分页查询账号列表 |
| `/mcn/api/tracks` | GET | 赛道列表(筛选下拉) |
| `/mcn/api/refresh` | POST | 从 MCP 重新拉取数据 |
| `/mcn/api/status` | GET | 本地缓存数量 |
## 说明
- 外部账号导入(/mcn/api/external/*)的字段归一化与「定位/内容」提取规范见 [docs/外部账号导入处理规范.md](docs/外部账号导入处理规范.md),处理新账号时复用
- 客户端 bundle 使用 `window.__ModuleLoader__.load` 格式,与官方 client 插件一致
- 侧边栏注入点:`sidebar.footer.action`(与插件商店相同位置)
- 修改 client.js / index.js 后需刷新页面生效;宿主端改动需重启 dsh 服务
- MCP 拉取依赖飞书登录态(复用 `~/.myai-mcp/credentials.json`)
+3
View File
@@ -0,0 +1,3 @@
- insert:
- id: mcn-nav
name: dsh-plugin-mcn
@@ -0,0 +1,59 @@
# dsh 插件 AI 会话任务输入输出对照(宿主版契约)
> 建立:2026-09-05(V1.0 工作台同步工程 P4 产物)| 权威性:宿主端任务入口的单一事实来源
> 对应 V1.0 版:`D:\AI技能\mcn-short-video\project\短视频脚本创作\V1.0\mcn-work-shop\docs\AI会话任务输入输出对照.md`
> 边界约定:dsh 侧任务一律由宿主创建**专属 AI 会话**执行(非 V1.0 automations 直写);技能挂载 = prompt 内声明技能路径(`~/.dsh/skills/<name>/`,全局注册);会话内调用本地接口用 pwsh(基地址 `$env:DSH_WEB_URL`,兜底 3081→3080)。
## 一、任务入口总表(触发端点 × 会话 × 技能 × 写回)
| # | 触发端点(POST) | 会话命名 | 工作区分组 | 并发/防重 | 技能(prompt 声明路径) | 写库回调(会话内调用) | 产物去向 |
|---|---|---|---|---|---|---|---|
| 1 | `/mcn/api/video/rewrite` {videoId} | `dsh_rewrite-{logId}` | 创作任务 | 8 并发;同会话 running→409 | short-video-script(S1-S11 改写) | `POST /mcn/api/rewrite/save {id,script}` | rewrite_log(video_id>0, gen_method=ref) |
| 2 | `/mcn/api/rewrite/original` {accountId, reqText?, attachText?} | `dsh_rewrite-{logId}` | 创作任务 | 8 并发;同会话 running→409 | short-video-script(S1-S11 原创) | 同上 | rewrite_log(video_id 空, gen_method=new)=原创选题中心 |
| 3 | `/mcn/api/account/persona/extract` {accountId} | `dsh_persona-{accountId}` | 解析任务 | 每账号独立;running/60s→409 | mcn-dou-analysis 功能三 | `external/import` data 模式 analyses.type=persona + `account/update`(content/track) | account_persona + hot_accounts |
| 4 | `/mcn/api/account/analysis/start` {accountId} | `dsh_acctana-{accountId}` | 解析任务 | 每账号独立;running/60s→409 | mcn-dou-analysis 功能四 | `external/import` data 模式 analyses.type=account | account_analysis |
| 5 | `/mcn/api/video/analyze` {videoId} | `dsh_video-analyze-{videoId}` | 解析任务 | 8 并发;running/60s→409 | mcn-dou-analysis 功能五 | `video/source/backfill`(幂等补录) | account_video_source/analysis + 文件 |
| 6 | `/mcn/api/storyboard/create` {rewriteId} | `dsh_storyboard-{logId}` | 分镜 | 8 并发 | mcn-video-prompt F4(storyboard-prompt) | `storyboard/save {id,script}` | storyboard_log |
| 7 | `/mcn/api/review/start` {logId,type} | `dsh_review-{type}-{id}` | 创作任务 | 4 并发;running→409 | mcn-script-review(script-review 复盘技能) | `review/save` | script_review(ai/compare 按 rewrite_id、source 按 video_id) |
| 8 | `/mcn/api/account/refresh-web` {id} | `dsh_refresh-{id}` | 解析任务 | 浏览器任务全局串行互斥 | mcn-dou-analysis(网页采集) | `external/import` data 模式 | hot_accounts/account_videos |
| 9 | `/mcn/api/external/stage` {dir\|account} | `dsh_import-{key}` / `dsh_external-import` | 解析任务 | 浏览器任务串行;60s 防抖 | mcn-dou-analysis(导入分析/网页采集) | `external/import` | hot_accounts 族 |
| 10 | `/mcn/api/ranking/update` {force?} | `dsh_rank-update`(固定会话 followup) | 其他/榜单更新 | 单任务;30min 冷却+在跑防重(`~/.dsh/mcn-rank-update.json`) | mcn-data-insight(落盘规范) | 无(文件落盘) | `抖音榜单/{账号周榜\|账号日榜\|视频热榜\|点赞榜}/` |
| 11 | `/mcn/api/creative` {prompt, sessionId?}(旧版自由创作,记 creative_log;新入口优先走 #2) | `dsh_short-video-script` / 指定 | 创作任务 | 固定会话串行 | prompt 自带 | 无(遗留兼容) | creative_log 计数 |
## 二、只读/写回辅助端点(非会话入口)
ranking 四榜 `GET /mcn/api/ranking?board=week|day|hot|likes`;custom-scripts/custom-script/rewrite-stats(原创中心);accounts(含 video_count/rewrite_count、排除 del_flag);videos(account 账号级放开爆款窗 / days 天数 / parsed);account/videos、account/persona、account/analysis;rewrite/list(复盘列表,含 review_* 摘要与 running);review/detail、review/full;storyboard/list;batch/delete(accounts 软删+videos/rewrites 连坐);account/favorite/update;task/status(含 creative/import/imports/analyze/persona/rewrite/review/accountAnalysis);sessions、session/detail;stats、tracks、external/preview、video/detail、video/script、video/source(backfill)。
## 三、会话生命周期约定(与 V1.0 差异点)
- 1 任务 = 1 会话(固定名复用除外:#10);会话按前缀归组(创作任务/解析任务/分镜/计划任务/对标创作/其他)。
- 完成后会话保留(供「MCN任务会话」查看);**完成清理**:结果已入库(taskDone)且 >30 分钟无写入 → 物理删除释放 MCP;**长期清理**:>24h 无活动且非运行中 → 删除。
- 会话删除防 id 复用污染:删除改写/分镜/复盘记录时同步删除对应会话目录(rewrite/delete 已内置,batch/delete 同)。
- 浏览器任务(#8/#9)全局串行互斥 + 固定调试 Chrome 9223 端口复用标签页(prompt 注入 chromeHint)。
## 四、新增/修改 AI 入口检查清单(宿主版,同步 V1.0 09-02 七项工程约束)
1. [ ] 端点语义:入参校验(账号/视频存在性、del_flag)、404/409/400 边界完整;并发与防重(同账号/同视频 60s 窗口)写明错误文案
2. [ ] 会话命名走既有前缀(config.js 常量),新前缀需登记:启动修复正则 / resume 分支 / 完成清理 / 会话列表分组 / taskDone 判定(index.js 五处)
3. [ ] prompt 结构:任务背景 → 「调用技能」声明(`~/.dsh/skills/<name>/`,需实际存在)→ 步骤(数据准备/样本/两段式/红线)→ 产物路径约定 → 写库回调(含 JSON 转义与编码警告:优先文件+curl --data-binary,禁 Invoke-RestMethod 直传中文)→ 完成汇报要求 → 沙箱拒绝时的 escalation 指引
4. [ ] 技能边界声明(禁止越权:提炼不抓取、复盘不改脚本、解析不创作等)
5. [ ] 浏览器类任务才有 chromeHint;非浏览器任务 prompt 禁止含「账号信息|视频列表|采集|导入账号|网页采集|浏览器」字样(防误触发浏览器锁语义——dsh 侧锁为宿主互斥非正则注入,但文案污染会误导模型)
6. [ ] 写库回调幂等:save/import 类接口服务端自动去重或覆盖语义写明
7. [ ] 验证:`node --check`;重启后接口冒烟(只读断言);真实任务用「功能验证账号白名单」;客户端改动强刷走查
## 五、V1.0 SKILL_HINT_* 到 dsh prompt 的等价映射
| V1.0 前缀常量 | dsh 宿主所在端点 | 说明 |
|---|---|---|
| SKILL_HINT_CREATE(S1-S11 自动模式) | video/rewrite、rewrite/original | 声明+业务正文在宿主拼接;人设/选题由服务端组装(video/rewrite)或客户端组装需求(original) |
| SKILL_HINT_EXTRACT | persona/extract | 等价 |
| SKILL_HINT_ANALYZE | account/analysis/start | 等价(09-04 新增对齐) |
| SKILL_HINT_VIDEO_PARSE | video/analyze | dsh 用 source/backfill 回写,无 unmatched 端点(对账结论见 Architecture/双端功能同步基线表.md) |
| SKILL_HINT_STORYBOARD | storyboard/create | 等价 |
| SKILL_HINT_REVIEW | review/start(三模式) | dsh 增加 source 视频级共享与 compare 前置语义 |
| SKILL_HINT_ACCOUNT | refresh-web / external/stage | 等价 |
| SKILL_HINT_RANKING | ranking/update | 等价(含幂等铁律与计费提醒) |
## 六、双端防漂移
任何一侧新增任务入口/技能契约变更:先更新 `dsh-plugin-dev/Architecture/双端功能同步基线表.md` 与本文档,再改代码;回归对照本表逐行核销。
@@ -0,0 +1,67 @@
# 外部账号数据导入处理规范
> 适用范围:`/mcn/api/external/stage → preview → import`(data 模式)链路中,AI 对
> `…/账号对标/<账号名>/` 文件夹的归一化处理。首次沉淀:2026-08-16 王微斯账号导入(1 账号 / 54 视频 / 14 分析)。
---
## 一、目录结构识别
| 文件 | 用途 |
|------|------|
| `账号信息.json` | 账号资料 → `accounts` |
| `短视频表格.xlsx` | 13 列视频表 → `videos`(54 条) |
| `*账号设定.md` | → `analyses[persona]`;**同时是「定位/内容」的提取来源** |
| `*账号数据分析.md` | → `analyses[account]` |
| `视频对标/<视频标题>/` 下:`*_拆解分析.md` + `原视频解析.json`(或 `content.json`+`analysis.json`) | → `analyses[video]`;json 的 `title` 用于匹配 xlsx 视频 |
> 注:`原视频解析.json` 与 `analysis.json`+`content.json` 是同一批解析数据的两种导出变体,
> 均含 `title`,匹配时先读 `原视频解析.json`、没有再读 `content.json`。
---
## 二、定位/内容(hot_accounts.content)提取规则 ★核心
1. **来源**:`*账号设定.md` 的「一、账号核心定位」章节(键:赛道 / 账号类型 / 核心受众 / 核心标签 / 内容路线 / 情绪锚点 …)。
2. **必须转纯文本段落**:去掉 markdown 表格样式(`|`、`---`、`**` 加粗标记),格式为 `键名:值` 逐行段落。
3. **不得与简介(bio)相同**:`content` 与 `bio` 是两个字段。
- 账号信息.json 里只有 bio,没有定位 → 定位一律从账号设定.md 提取;
- 提取不到时才允许用 `bio` 兜底(代码层已实现 `content: info.content || info.bio`,data 模式传 `content` 优先)。
4. **赛道(hot_accounts.track)**:从「账号核心定位」的「赛道」键提取,**取第一个主赛道**(`" / "` 分隔取首段),如 `生活vlog赛道`;代码层已支持 `track: info.track`。
4. **示例(王微斯,已入库)**:
```
赛道:生活vlog赛道 / 甜宠情感剧情 / 乙女向沉浸体验
账号类型:固定主角+轮换搭档——女主王微斯固定不变,每期搭讪不同类型高颜值男主,但风格统一、情绪底色一致
核心受众:18-35岁女性、磕糖爱好者、颜控党、乙女向代入感爱好者,对浪漫邂逅有情感投射的年轻群体
核心标签:第一视角沉浸撩汉 + 直球拽姐拿捏 + 纯情帅哥反差萌破防
内容路线:街头搭讪破冰 → 极限推拉互撩 → 肢体接触升温 → 深夜交心破防 → 浪漫收尾升华
情绪锚点:王微斯 = 替所有女生完成"主动追帅哥"的爽文体验,每期换一个帅哥被她拿捏
```
---
## 三、其余归一化规则(沿用)
### accounts
- `nickname → account_name`;`douyin_id` / `sec_uid` 保持字符串;
- `followers` / `total_likes` 保留展示格式(如 `409.2万`);`works_count` / `age` 转 int;
- 丢弃非标准字段(如 `video_count_collected`);可附 `source_dir` 溯源。
### videos(xlsx 13 列)
- 列名别名匹配:视频ID / 视频标题 / 视频地址 / 点赞数 / 点赞(显示) / 评论数 / 分享数 / 收藏数 / 播放量 / 视频时长 / 发布时间 / 标签;
- `tags` 按逗号拆分数组(保留 `#` 前缀);
- 数字列转 int;`duration` / `publish_time` 保留源格式字符串;
- 播放量为 0 属源数据缺失,照实入库。
### analyses
- `persona`:`*账号设定.md` 全文;
- `account`:`*账号数据分析.md` 全文;
- `video`:`*_拆解分析.md` 全文;`video_aweme_id` **必须**匹配到 xlsx 视频:
匹配优先级 = json title 精确 → 归一化包含(去空白/标点)→ 相似度 >0.8 → md 头部「视频标题」→ 文件夹名首段;
- `summary`:优先取 md 内「结论/核心定位/核心结论」章节,取不到用开头 200 字。
### 其他
- 去重由脚本层处理(content 字节精确比对);
- ⚠️ Python 读文件默认 CRLF→LF 归一,需按源文件**字节精确**回写(`open(p,'rb').read().decode('utf-8')`),否则 dir 模式去重比对失败;
- 大体积 data 请求需服务端 readBody 已修复(Buffer 拼接解码),否则多字节字符可能损坏为 U+FFFD。
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
// dsh-plugin-mcn 配置常量(拆分自 index.js,纯常量无业务逻辑)
import { homedir } from "node:os";
import { join, isAbsolute, resolve } from "node:path";
import { readFileSync, existsSync } from "node:fs";
const DB_PATH = join(homedir(), ".dsh", "mcn-plugin.db");
// 数据预览统计缓存(60s TTL,避免每次从数据库刷新)
const STATS_TTL = 60 * 1000;
// 创作专用会话:固定命名,已存在则复用
const CREATIVE_SESSION_ID = "dsh_short-video-script";
// 创作 agent 的模型配置(与 web profile 默认一致,避免 {{model}} 变量无值)
const CREATIVE_PROVIDER = "deepseek-official";
const CREATIVE_MODEL = "deepseek-v4-flash";
const MCP_PACKAGE = "myai-mcp";
const MCP_ENV = {
MYAI_ENV: "prod",
MYAI_API_BASE_URL: "https://maiya-trans.youmanvideo.com/api",
MYAI_FEISHU_APP_ID: "cli_a84d0a47f93e1013",
MYAI_VOD_URL_PREFIX: "https://maiya-ai-fileup.youmanvideo.com",
MYAI_VOD_SPACE_NAME: "maiya-ai",
};
const NPX_CMD = process.platform === "win32"
? '"D:\\Program Files\\nodejs\\npx.cmd"'
: "npx";
function resolveCreativeCwd() {
try {
const raw = readFileSync(join(homedir(), ".dsh", "storages", "workspace.json"), "utf8");
const data = JSON.parse(raw);
const workspaces = data?.tables?.workspaces;
if (workspaces) {
const candidates = Object.values(workspaces)
.filter((ws) => typeof ws?.path === "string" && existsSync(ws.path))
.sort((a, b) => String(a.path).split(/[\\/]/).length - String(b.path).split(/[\\/]/).length);
if (candidates.length > 0) return candidates[0].path;
}
} catch (e) { /* fall through */ }
return "D:\\dshworkspace";
}
/** 并发浏览器任务隔离:调试 Chrome 端口池(需预启动对应实例),按会话 id 稳定分配。
* 导入/刷新等浏览器类任务各自使用独立端口,避免多个 AI 会话抢同一 Chrome。 */
const CHROME_PORTS = [9223, 9224, 9225];
const IMPORT_PREFIX = "dsh_import-";
const REFRESH_PREFIX = "dsh_refresh-";
const VIDEO_ANALYZE_PREFIX = "dsh_video-analyze-";
const REWRITE_PREFIX = "dsh_rewrite-";
const PERSONA_PREFIX = "dsh_persona-";
const STORYBOARD_PREFIX = "dsh_storyboard-";
const REVIEW_PREFIX = "dsh_review-";
// 账号数据分析会话前缀(V1.0 同步:账号详情「数据分析」tab 任务,每账号独立会话,归「解析任务」分组)
const ACCT_ANALYSIS_PREFIX = "dsh_acctana-";
const VIDEO_ANALYZE_MAX_CONCURRENT = 8;
const REWRITE_MAX_CONCURRENT = 8;
const STORYBOARD_MAX_CONCURRENT = 8;
const REVIEW_MAX_CONCURRENT = 4;
const MAX_UNGROUPED = 10;
const SESSION_ACTIVE_WINDOW_MS = 15 * 60 * 1000;
// MCN 任务会话自动删除:最后活动时间距今超过该时长(24h)且不在运行中的 dsh_* 会话,物理删除会话记录
const SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
// ---- 榜单更新任务(同步 V1.0 工作台 09-03 机制:30min 防重复更新窗口,可 force 跳过;红狐 API 按次计费)----
const RANK_UPDATE_COOLDOWN_MS = 30 * 60 * 1000;
// 榜单更新专用会话(固定命名,与创作会话同模式:已存在复用、followup 触发)
const RANK_SESSION_ID = "dsh_rank-update";
// 榜单更新元数据(跨重启持久化:lastAt/running/lastOk/lastError)
const RANK_META_FILE = join(homedir(), ".dsh", "mcn-rank-update.json");
// 榜单更新任务超时兜底:提交超过该时长仍标记 running → 视为中断(自愈置回空闲)
const RANK_TASK_MAX_MS = 60 * 60 * 1000;
/** 赛道标准枚举(28 个标准,其中「化妆美容」统一为「美妆」+ 剧情 1 个用户指定保留,共 29 个;权威定义见 dsh-plugin-dev/references/赛道标准枚举.md 与技能 SKILL.md) */
const STANDARD_TRACKS = [
"个人才艺", "生活vlog", "财富理财", "二次元", "居家装修", "学习教育", "小剧场", "数码科技",
"旅行", "美食", "美妆", "动物", "亲子", "汽车", "情感", "三农", "健康医学", "潮流风尚",
"舞蹈才艺", "颜值造型", "人文", "音乐", "影视", "身体锻炼", "体育", "明星娱乐", "游戏", "其他",
"剧情",
];
export {
DB_PATH, STATS_TTL, CREATIVE_SESSION_ID, CREATIVE_PROVIDER, CREATIVE_MODEL,
MCP_PACKAGE, MCP_ENV, NPX_CMD, CHROME_PORTS,
IMPORT_PREFIX, REFRESH_PREFIX, VIDEO_ANALYZE_PREFIX, REWRITE_PREFIX, PERSONA_PREFIX, STORYBOARD_PREFIX, REVIEW_PREFIX, ACCT_ANALYSIS_PREFIX,
VIDEO_ANALYZE_MAX_CONCURRENT, REWRITE_MAX_CONCURRENT, STORYBOARD_MAX_CONCURRENT, REVIEW_MAX_CONCURRENT, MAX_UNGROUPED,
SESSION_ACTIVE_WINDOW_MS, SESSION_RETENTION_MS, STANDARD_TRACKS, resolveCreativeCwd,
RANK_UPDATE_COOLDOWN_MS, RANK_SESSION_ID, RANK_META_FILE, RANK_TASK_MAX_MS,
};
+106
View File
@@ -0,0 +1,106 @@
// dsh-plugin-mcn 数据访问层(拆分自 index.js)
import { initDb } from "./db.js";
import { callMcpTool } from "./mcp.js";
import { STANDARD_TRACKS } from "./config.js";
async function ensureData() {
const d = initDb();
const row = d.prepare("SELECT COUNT(*) AS c FROM hot_accounts").get();
return { cached: row.c > 0, count: row.c };
}
async function refreshData() {
const d = initDb();
const result = await callMcpTool("list_hot_accounts", {});
// 返回结构:{ structured: {total, accounts: [...]} | null, text: "..." }
let accounts = null;
if (result?.structured && Array.isArray(result.structured.accounts)) {
accounts = result.structured.accounts;
}
if (!accounts) {
throw new Error("MCP 返回缺少结构化账号数组(可能未登录或服务异常),无法入库");
}
const insert = d.prepare(`INSERT OR REPLACE INTO hot_accounts
(id, account_name, content, track, tags, masterpiece, created_by, updated_by, created_time, updated_time, sort, del_flag, account_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'signed')`);
d.exec("BEGIN");
try {
for (const a of accounts) {
insert.run(
a.id, a.accountName ?? "", a.content ?? "", a.track ?? "",
a.tags ?? "", a.masterpiece ?? "", a.createdBy ?? "", a.updatedBy ?? "",
a.createdTime ?? "", a.updatedTime ?? "", a.sort ?? 0, a.delFlag ?? 0
);
}
d.exec("COMMIT");
} catch (e) {
d.exec("ROLLBACK");
throw e;
}
return { cached: false, count: accounts.length };
}
function queryAccounts({ page = 1, pageSize = 20, search = "", track = "", type = "all", sortBy = "id", order = "asc" }) {
const d = initDb();
const where = [];
const params = [];
// 软删账号不展示(同步 V1.0 工作台:批量删除=del_flag=1)
where.push("(del_flag IS NULL OR del_flag <> 1)");
if (search) {
where.push("(account_name LIKE ? OR content LIKE ? OR track LIKE ?)");
const like = `%${search}%`;
params.push(like, like, like);
}
if (track) {
where.push("track = ?");
params.push(track);
}
// 账号类型筛选:all / signed(签约)/ external(外部)
if (type === "signed" || type === "external") {
where.push("account_type = ?");
params.push(type);
}
const whereSql = where.length ? "WHERE " + where.join(" AND ") : "";
const allowedSort = ["id", "account_name", "track", "sort"];
const sortCol = allowedSort.includes(sortBy) ? sortBy : "id";
const orderSql = order === "desc" ? "DESC" : "ASC";
const total = d.prepare(`SELECT COUNT(*) AS c FROM hot_accounts ${whereSql}`).get(...params).c;
const limit = Math.max(1, Number(pageSize) || 20);
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
// 排序:收藏的账号排最前,收藏/未收藏各自内部按粉丝数降序(followers 支持 "万"/"亿" 格式解析为数值)
const followersExpr = `CASE WHEN h.followers LIKE '%亿%' THEN CAST(REPLACE(REPLACE(h.followers,'亿',''),'万','') AS REAL) * 100000000 WHEN h.followers LIKE '%万%' THEN CAST(REPLACE(h.followers,'万','') AS REAL) * 10000 ELSE CAST(h.followers AS REAL) END`;
const orderBySql = `h.favorite DESC, ${followersExpr} DESC, h.id ASC`;
// 附带视频数(account_videos 关联统计,供前端「视频数」列展示)与 AI脚本数(挂该账号视频的 ref 脚本 + video_id 空的原创脚本,同步 V1.0 账号列表「AI脚本」列口径)
const rows = d.prepare(`SELECT h.*,
(SELECT COUNT(*) FROM account_videos v WHERE v.account_id = h.id) AS video_count,
(SELECT COUNT(*) FROM rewrite_log r WHERE r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '' AND ((r.video_id > 0 AND r.video_id IN (SELECT id FROM account_videos v2 WHERE v2.account_id = h.id)) OR ((r.video_id IS NULL OR r.video_id = 0) AND r.account_id = h.id))) AS rewrite_count
FROM hot_accounts h ${whereSql} ORDER BY ${orderBySql} LIMIT ? OFFSET ?`)
.all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: rows };
}
function listTracks() {
const d = initDb();
const dbTracks = d.prepare(`SELECT DISTINCT track FROM hot_accounts WHERE track IS NOT NULL AND track != '' ORDER BY track`).all().map((r) => r.track);
// 标准 28 个 + 库内非标值并集(保证旧数据可筛选)
const extras = dbTracks.filter((t) => !STANDARD_TRACKS.includes(t));
return STANDARD_TRACKS.concat(extras);
}
function queryAccountVideos(accountId, page = 1, pageSize = 20) {
const d = initDb();
const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=? AND (del_flag IS NULL OR del_flag <> 1)`).get(accountId);
if (!acc) throw new Error("账号不存在");
const total = d.prepare(`SELECT COUNT(*) AS c FROM account_videos WHERE account_id=?`).get(accountId).c;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const items = d.prepare(`
SELECT v.*,
(SELECT COUNT(*) FROM account_video_source s WHERE s.video_id = v.id OR s.aweme_id = v.aweme_id) AS has_source,
(SELECT COUNT(*) FROM account_video_analysis a WHERE a.video_id = v.id OR a.aweme_id = v.aweme_id) AS has_analysis
FROM account_videos v WHERE v.account_id=? ORDER BY v.id ASC LIMIT ? OFFSET ?
`).all(accountId, limit, offset);
return { account: acc, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
export { ensureData, refreshData, queryAccounts, listTracks, queryAccountVideos };
+299
View File
@@ -0,0 +1,299 @@
// dsh-plugin-mcn 本地 SQLite 缓存(node:sqlite,Node 24 内置;拆分自 index.js)
import { DatabaseSync } from "node:sqlite";
import { join } from "node:path";
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { DB_PATH } from "./config.js";
import { normTitle, titleDistance, findVideoByFolder, resolveAccountDirs } from "./video-tools.js";
let db;
/** 从 AI 写作脚本正文提取标题(兼容现有多种首行格式):
* - 去掉 markdown 标题标记(# 前缀)
* - 去掉「S9 故事脚本」等前缀
* - 优先取《…》内内容,否则用整行
* - 限制长度 80 字符,空则返回 null
*/
function extractScriptTitle(scriptText) {
if (typeof scriptText !== "string" || !scriptText.trim()) return null;
let line = scriptText.replace(/\r\n/g, "\n").split("\n").find((l) => l.trim()) || "";
let t = line.trim();
t = t.replace(/^#+\s*/, "").trim(); // 去 markdown 标题标记
t = t.replace(/^S\d+\s*(故事)?(脚|剧)?本\s*/, "").trim(); // 去「S9 故事脚本」等前缀
const m = t.match(/《([^》]+)》/);
if (m && m[1].trim()) t = m[1].trim();
t = t.replace(/^#+\s*/, "").trim();
return t ? t.slice(0, 80) : null;
}
function initDb() {
if (db) return db;
db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS hot_accounts (
id INTEGER PRIMARY KEY,
account_name TEXT NOT NULL,
content TEXT,
track TEXT,
tags TEXT,
masterpiece TEXT,
created_by TEXT,
updated_by TEXT,
created_time TEXT,
updated_time TEXT,
sort INTEGER DEFAULT 0,
del_flag INTEGER DEFAULT 0,
account_type TEXT DEFAULT 'signed',
favorite INTEGER DEFAULT 0
);
`);
// 存量数据库迁移:旧表可能缺 account_type 列(CREATE TABLE IF NOT EXISTS 不会为已存在表加列)
const cols = db.prepare(`PRAGMA table_info(hot_accounts)`).all().map((c) => c.name);
if (!cols.includes("account_type")) {
db.exec(`ALTER TABLE hot_accounts ADD COLUMN account_type TEXT DEFAULT 'signed'`);
}
if (!cols.includes("favorite")) {
db.exec(`ALTER TABLE hot_accounts ADD COLUMN favorite INTEGER DEFAULT 0`);
}
// 外部账号扩展列(幂等迁移)
const extCols = {
douyin_id: "TEXT",
sec_uid: "TEXT",
followers: "TEXT",
total_likes: "TEXT",
works_count: "INTEGER",
ip_location: "TEXT",
location: "TEXT",
age: "INTEGER",
bio: "TEXT",
source_dir: "TEXT",
};
for (const [name, type] of Object.entries(extCols)) {
if (!cols.includes(name)) {
db.exec(`ALTER TABLE hot_accounts ADD COLUMN ${name} ${type}`);
}
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_hot_accounts_name ON hot_accounts(account_name);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_hot_accounts_track ON hot_accounts(track);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_hot_accounts_type ON hot_accounts(account_type);`);
// 存量数据兼容:无 account_type 的按 signed 处理
try { db.exec(`UPDATE hot_accounts SET account_type='signed' WHERE account_type IS NULL OR account_type=''`); } catch (e) {}
// 外部账号关联表(见 Architecture/外部账号数据结构设计.md)
db.exec(`
CREATE TABLE IF NOT EXISTS account_videos (
id INTEGER PRIMARY KEY,
account_id INTEGER,
aweme_id TEXT,
video_title TEXT,
video_url TEXT,
like_count INTEGER DEFAULT 0,
like_display TEXT,
comment_count INTEGER DEFAULT 0,
share_count INTEGER DEFAULT 0,
collect_count INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
duration TEXT,
publish_time TEXT,
tags TEXT,
collected_time TEXT,
source_dir TEXT,
topic TEXT,
UNIQUE(account_id, aweme_id)
);
CREATE TABLE IF NOT EXISTS account_video_analysis (
id INTEGER PRIMARY KEY,
video_id INTEGER,
aweme_id TEXT,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS account_video_source (
id INTEGER PRIMARY KEY,
video_id INTEGER,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS account_persona (
id INTEGER PRIMARY KEY,
account_id INTEGER,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS account_analysis (
id INTEGER PRIMARY KEY,
account_id INTEGER,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS creative_log (
id INTEGER PRIMARY KEY,
created_time TEXT,
source TEXT
);
CREATE TABLE IF NOT EXISTS rewrite_log (
id INTEGER PRIMARY KEY,
video_id INTEGER,
aweme_id TEXT,
account_id INTEGER,
script_text TEXT,
topic TEXT,
status TEXT DEFAULT 'pending',
created_time TEXT,
updated_time TEXT
);
CREATE TABLE IF NOT EXISTS storyboard_log (
id INTEGER PRIMARY KEY,
video_id INTEGER,
rewrite_id INTEGER,
script_text TEXT,
status TEXT DEFAULT 'pending',
created_time TEXT,
updated_time TEXT
);
CREATE TABLE IF NOT EXISTS script_review (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
rewrite_id INTEGER,
video_id INTEGER,
dims_json TEXT,
total TEXT,
verdict TEXT,
issues_json TEXT,
content TEXT,
summary TEXT,
status TEXT DEFAULT 'pending',
created_time TEXT,
updated_time TEXT
);
CREATE INDEX IF NOT EXISTS idx_script_review_type ON script_review(type);
CREATE INDEX IF NOT EXISTS idx_script_review_rewrite ON script_review(rewrite_id);
CREATE INDEX IF NOT EXISTS idx_script_review_video ON script_review(video_id);
`);
// 存量迁移:老库 account_videos 缺 topic 列(选题,从源视频解析 json 提取)
try {
const tcols = db.prepare(`PRAGMA table_info(account_videos)`).all().map((c) => c.name);
if (!tcols.includes("topic")) db.exec(`ALTER TABLE account_videos ADD COLUMN topic TEXT`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:rewrite_log 缺 title 列(AI 写作脚本标题,从脚本首行提取)
try {
const rwcols = db.prepare(`PRAGMA table_info(rewrite_log)`).all().map((c) => c.name);
if (!rwcols.includes("title")) db.exec(`ALTER TABLE rewrite_log ADD COLUMN title TEXT`);
// 存量回填:仅 title 为空且脚本非空时,从脚本首行提取(幂等)
for (const row of db.prepare(`SELECT id, script_text FROM rewrite_log WHERE (title IS NULL OR title = '') AND script_text IS NOT NULL AND script_text <> ''`).all()) {
const title = extractScriptTitle(row.script_text);
if (title) db.prepare(`UPDATE rewrite_log SET title=? WHERE id=?`).run(title, row.id);
}
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:account_video_analysis 缺 aweme_id 列(拆解分析关联视频ID,业务主键)
try {
const acols = db.prepare(`PRAGMA table_info(account_video_analysis)`).all().map((c) => c.name);
if (!acols.includes("aweme_id")) db.exec(`ALTER TABLE account_video_analysis ADD COLUMN aweme_id TEXT`);
db.exec(`UPDATE account_video_analysis SET aweme_id = (SELECT aweme_id FROM account_videos WHERE id = account_video_analysis.video_id) WHERE aweme_id IS NULL AND video_id IS NOT NULL`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:account_video_source 缺 aweme_id 列(原视频解析关联视频ID,业务主键)
try {
const scols = db.prepare(`PRAGMA table_info(account_video_source)`).all().map((c) => c.name);
if (!scols.includes("aweme_id")) db.exec(`ALTER TABLE account_video_source ADD COLUMN aweme_id TEXT`);
if (!scols.includes("analysis_json")) db.exec(`ALTER TABLE account_video_source ADD COLUMN analysis_json TEXT`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:hot_accounts 缺 is_ai 列(是否AI账号,默认「否」,赛道字段后展示)
try {
const hcols = db.prepare(`PRAGMA table_info(hot_accounts)`).all().map((c) => c.name);
if (!hcols.includes("is_ai")) db.exec(`ALTER TABLE hot_accounts ADD COLUMN is_ai TEXT DEFAULT '否'`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:hot_accounts 缺 del_flag 列(软删标记,同步 V1.0 工作台:批量删除账号=软删+连坐清关联)
try {
const hdcols = db.prepare(`PRAGMA table_info(hot_accounts)`).all().map((c) => c.name);
if (!hdcols.includes("del_flag")) db.exec(`ALTER TABLE hot_accounts ADD COLUMN del_flag INTEGER DEFAULT 0`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:rewrite_log 缺 gen_method 列(生成方式:ref=参考选题创作 / new=原创选题创作 / abc=ABC融合创作;
// 同步 V1.0 工作台 v25 三 tab 口径;历史行按 video_id 是否为空回填)
try {
const gwcols = db.prepare(`PRAGMA table_info(rewrite_log)`).all().map((c) => c.name);
if (!gwcols.includes("gen_method")) db.exec(`ALTER TABLE rewrite_log ADD COLUMN gen_method TEXT DEFAULT 'ref'`);
db.exec(`UPDATE rewrite_log SET gen_method='new' WHERE (video_id IS NULL OR video_id = 0) AND (gen_method IS NULL OR gen_method = '')`);
db.exec(`UPDATE rewrite_log SET gen_method='ref' WHERE (video_id IS NOT NULL AND video_id > 0) AND (gen_method IS NULL OR gen_method = '')`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量清洗:赛道值去掉「赛道」后缀(如「生活vlog赛道」→「生活vlog」,全库统一;幂等)
try {
db.exec(`UPDATE hot_accounts SET track = REPLACE(track, '赛道', '') WHERE track LIKE '%赛道%'`);
} catch (e) { /* 清洗失败不阻塞 */ }
// 存量修复:source 按 content_json 标题匹配当前 account_videos,回填 aweme_id 并修正错位 video_id
// (早期视频重建后自增 id 失效,旧记录指向已删除视频;aweme_id 为业务主键不受重建影响)
try { backfillSourceAwemeIds(db); } catch (e) { console.warn(`[dsh-plugin-mcn] source 回填失败: ${e.message}`); }
return db;
}
function backfillSourceAwemeIds(d) {
// 补 analysis_json(每次启动执行,不依赖 aweme_id 回填):文件系统里存在 analysis.json 的记录(按账号内标题匹配)
try {
for (const acc of d.prepare(`SELECT id, account_name, source_dir FROM hot_accounts`).all()) {
for (const dir of resolveAccountDirs(acc)) {
const vsDir = join(dir, "视频对标");
if (!existsSync(vsDir)) continue;
for (const e of readdirSync(vsDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const vDir = join(vsDir, e.name);
const ap = join(vDir, "analysis.json");
if (!existsSync(ap)) continue;
const vd = findVideoByFolder(d, acc.id, vDir, e.name);
if (!vd) continue;
const row = d.prepare(`SELECT id FROM account_video_source WHERE aweme_id=? OR video_id=?`).get(vd.aweme_id, vd.id);
if (row) {
const aj = readFileSync(ap, "utf8");
d.prepare(`UPDATE account_video_source SET analysis_json=? WHERE id=?`).run(aj, row.id);
}
}
}
}
} catch (e) { console.warn(`[dsh-plugin-mcn] analysis_json 补录失败: ${e.message}`); }
const rows = d.prepare(`SELECT id, video_id, aweme_id, content_json FROM account_video_source`).all();
const missing = rows.filter((r) => !r.aweme_id);
if (missing.length === 0) return 0;
const all = d.prepare(`SELECT id, aweme_id, video_title FROM account_videos`).all();
const find = (title) => {
const t = String(title || "").trim();
if (!t) return null;
const exact = all.find((v) => String(v.video_title || "") === t);
if (exact) return exact;
const tn = normTitle(t);
if (!tn) return null;
let best = null;
for (const v of all) {
const cn = normTitle(v.video_title);
if (!cn || cn.length < 6 || tn.length < 6) continue;
if (tn.includes(cn) || cn.includes(tn)) {
if (!best || tn.length > best._len) { best = { ...v, _len: tn.length }; }
}
}
if (best) return best;
for (const v of all) {
const cn = normTitle(v.video_title);
if (!cn) continue;
const ratio = 1 - titleDistance(tn, cn) / Math.max(tn.length, cn.length, 1);
if (ratio > 0.8 && (!best || ratio > best._ratio)) { best = { ...v, _ratio: ratio }; }
}
return best;
};
let fixed = 0;
for (const r of missing) {
let title = null;
try {
const jd = JSON.parse(r.content_json);
title = jd && jd.title != null ? String(jd.title) : null;
} catch (e) { /* 单个解析失败跳过 */ }
const hit = title ? find(title) : null;
if (hit && hit.aweme_id) {
d.prepare(`UPDATE account_video_source SET aweme_id=?, video_id=? WHERE id=?`).run(hit.aweme_id, hit.id, r.id);
fixed++;
}
}
// 补 analysis_json 完成(已上移至函数开头执行,保证每次启动都补录)
if (fixed > 0) console.log(`[dsh-plugin-mcn] source 表回填 ${fixed}/${missing.length} 条 aweme_id(标题匹配修复错位)`);
return fixed;
}
export { initDb, backfillSourceAwemeIds, extractScriptTitle };
+33
View File
@@ -0,0 +1,33 @@
// dsh-plugin-mcn HTTP 路由工具(拆分自 index.js)
function parseQuery(url) {
const u = new URL(url, "http://localhost");
const out = {};
for (const [k, v] of u.searchParams) out[k] = v;
return out;
}
function json(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
}
function readBody(req) {
return new Promise((resolvePromise, reject) => {
// 收集 Buffer 后一次性按 UTF-8 解码:逐 chunk 直接拼字符串会独立解码各 chunk,
// 多字节字符横跨 chunk 边界时被损坏为 U+FFFD(大体积 data 导入必现)
const chunks = [];
req.on("data", (c) => { chunks.push(c); });
req.on("end", () => {
try {
const data = Buffer.concat(chunks).toString("utf8");
resolvePromise(data ? JSON.parse(data) : {});
} catch (e) {
reject(new Error("请求体 JSON 解析失败:请以 UTF-8 发送 JSON。若用 PowerShell 调用,勿用 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码导致此错误),请改用 curl.exe --data-binary @文件 或 [System.Text.Encoding]::UTF8.GetBytes() 字节体"));
}
});
req.on("error", reject);
});
}
export { parseQuery, json, readBody };
+592
View File
@@ -0,0 +1,592 @@
// dsh-plugin-mcn 外部账号导入/读取(拆分自 index.js)
import { join } from "node:path";
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import XLSX from "xlsx";
import { initDb } from "./db.js";
import { num, normTitle, extractTopic, titleDistance, findVideoByFolder, resolveAccountDirs, resolveAccountDir } from "./video-tools.js";
function isInternalAccount(d, name, douyinId) {
if (douyinId) {
const byDid = d.prepare(`SELECT id, account_name, douyin_id FROM hot_accounts WHERE account_type='signed' AND douyin_id=?`).get(String(douyinId));
if (byDid) return byDid;
}
if (name) {
const byName = d.prepare(`SELECT id, account_name, douyin_id FROM hot_accounts WHERE account_type='signed' AND account_name=?`).get(name);
if (byName) return byName;
}
return null;
}
function upsertExternalAccount(d, info, sourceDir) {
// 兼容两种字段名:网页采集路径用 nickname(账号信息.json),data 模式导入用 account_name(payload)
const name = info.nickname || info.account_name || "";
const did = info.douyin_id != null ? String(info.douyin_id) : null;
// 结合 MCP:同名/同抖音号命中内部签约账号 → 提示(不阻断,供调用方/用户识别)
const signed = isInternalAccount(d, name, did);
if (signed) {
console.warn(`[dsh-plugin-mcn] 账号「${name || did}」命中内部签约账号(signed id=${signed.id}),可能非外部达人,请确认`);
}
const existing = did
? d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND douyin_id=?`).get(did)
: d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND account_name=?`).get(name);
const vals = {
account_name: name,
content: info.content || info.bio || "",
track: info.track || null,
douyin_id: did,
sec_uid: info.sec_uid != null ? String(info.sec_uid) : null,
followers: info.followers != null ? String(info.followers) : null,
total_likes: info.total_likes != null ? String(info.total_likes) : null,
works_count: info.works_count != null ? Number(info.works_count) : null,
ip_location: info.ip_location || null,
location: info.location || null,
age: info.age != null ? Number(info.age) : null,
bio: info.bio || null,
source_dir: sourceDir || null,
};
if (existing) {
d.prepare(`UPDATE hot_accounts SET account_name=?, content=?, track=?, douyin_id=?, sec_uid=?, followers=?, total_likes=?, works_count=?, ip_location=?, location=?, age=?, bio=?, source_dir=? WHERE id=?`)
.run(vals.account_name, vals.content, vals.track, vals.douyin_id, vals.sec_uid, vals.followers, vals.total_likes, vals.works_count, vals.ip_location, vals.location, vals.age, vals.bio, vals.source_dir, existing.id);
return existing.id;
}
const r = d.prepare(`INSERT INTO hot_accounts (account_name, content, account_type, track, douyin_id, sec_uid, followers, total_likes, works_count, ip_location, location, age, bio, source_dir) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(vals.account_name, vals.content, "external", vals.track, vals.douyin_id, vals.sec_uid, vals.followers, vals.total_likes, vals.works_count, vals.ip_location, vals.location, vals.age, vals.bio, vals.source_dir);
return Number(r.lastInsertRowid);
}
function importVideosFromXlsx(d, xlsxPath, accountId, sourceDir) {
const wb = XLSX.readFile(xlsxPath);
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
if (!rows.length) return 0;
const header = rows[0].map((h) => String(h ?? "").trim().toLowerCase());
// 列名别名匹配(外部表格表头可能用不同写法)
const findCol = (...aliases) => header.findIndex((h) => aliases.includes(h));
const iId = findCol("视频id", "aweme_id", "视频id(显示)"), iTitle = findCol("视频标题", "标题", "video_title"), iUrl = findCol("视频地址", "视频链接", "video_url"),
iLike = findCol("点赞数", "点赞", "like_count"), iLikeD = findCol("点赞(显示)", "点赞显示"),
iCmt = findCol("评论数", "评论", "comment_count"), iShare = findCol("分享数", "分享", "share_count"),
iCollect = findCol("收藏数", "收藏", "collect_count"), iPlay = findCol("播放量", "播放", "play_count"),
iDur = findCol("视频时长", "时长", "duration"), iPub = findCol("发布时间", "时间", "publish_time"),
iTags = findCol("标签", "tags");
// upsert:保留行 id(REPLACE 会删旧插新导致 id 漂移,拆解分析 video_id 关联断裂)
const ins = d.prepare(`INSERT INTO account_videos
(account_id, aweme_id, video_title, video_url, like_count, like_display, comment_count, share_count, collect_count, play_count, duration, publish_time, tags, collected_time, source_dir)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(account_id, aweme_id) DO UPDATE SET
video_title=excluded.video_title, video_url=excluded.video_url,
like_count=excluded.like_count, like_display=excluded.like_display,
comment_count=excluded.comment_count, share_count=excluded.share_count,
collect_count=excluded.collect_count, play_count=excluded.play_count,
duration=excluded.duration, publish_time=excluded.publish_time,
tags=excluded.tags, collected_time=excluded.collected_time, source_dir=excluded.source_dir`);
const now = new Date().toISOString();
let n = 0;
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
if (!row || row[iId] == null) continue;
ins.run(accountId, String(row[iId]),
row[iTitle] != null ? String(row[iTitle]) : "",
row[iUrl] != null ? String(row[iUrl]) : "",
num(row[iLike]), row[iLikeD] != null ? String(row[iLikeD]) : "",
num(row[iCmt]), num(row[iShare]), num(row[iCollect]), num(row[iPlay]),
row[iDur] != null ? String(row[iDur]) : "",
row[iPub] != null ? String(row[iPub]) : "",
row[iTags] != null ? String(row[iTags]) : "",
now, sourceDir);
n++;
}
return n;
}
function readVideoSource(videoId) {
const d = initDb();
const v = d.prepare(`
SELECT v.*, h.source_dir AS acc_dir, h.account_name AS acc_name FROM account_videos v
LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.id=?
`).get(videoId);
if (!v) return { found: false };
// 优先查库(account_video_source,按视频ID或自增 id 双键关联)
const row = d.prepare(
`SELECT content_json AS content, analysis_time FROM account_video_source WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?) ORDER BY id DESC LIMIT 1`
).get(videoId, v.aweme_id);
if (row && row.content) return { found: true, content: row.content, fileName: "db" };
const vt = String(v.video_title || "").trim();
const vn = vt ? normTitle(vt) : "";
const match = (title, dirName) => {
const t = String(title || "").trim();
if (!t) return false;
if (vt && t === vt) return true;
const tn = normTitle(t);
if (tn && vn && tn === vn) return true;
if (tn && vn && tn.length >= 6 && vn.length >= 6 && (tn.includes(vn) || vn.includes(tn))) return true;
if (tn && vn && vn.length >= 6) {
const ratio = 1 - titleDistance(tn, vn) / Math.max(tn.length, vn.length, 1);
if (ratio > 0.8) return true;
}
const first = String(dirName || "").split(/[__]/)[0].trim();
if (first && normTitle(first) && normTitle(first) === vn) return true;
return false;
};
const bases = [...resolveAccountDirs({ source_dir: v.acc_dir, account_name: v.acc_name }), v.source_dir]
.filter((x) => typeof x === "string" && x);
for (const base of bases) {
const vsDir = join(base, "视频对标");
if (!existsSync(vsDir)) continue;
for (const entry of readdirSync(vsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const vDir = join(vsDir, entry.name);
for (const jf of ["原视频解析.json", "content.json"]) {
const jp = join(vDir, jf);
if (!existsSync(jp)) continue;
try {
const jd = JSON.parse(readFileSync(jp, "utf8"));
if (match(jd.title, entry.name)) {
const content = readFileSync(jp, "utf8");
// 补录进库(幂等:同视频+同内容去重;带 aweme_id 业务主键;同时补 analysis_json)
try {
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(videoId, v.aweme_id, content);
const ap = join(vDir, "analysis.json");
let analysisJson = null;
if (existsSync(ap)) {
try { analysisJson = readFileSync(ap, "utf8"); } catch (e) { /* analysis 读取失败忽略 */ }
}
if (!dup) {
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary, analysis_json) VALUES (?,?,?,?,?,?)`)
.run(videoId, v.aweme_id, statSync(jp).mtime.toISOString(), content, "", analysisJson);
} else if (analysisJson) {
d.prepare(`UPDATE account_video_source SET analysis_json=? WHERE id=?`).run(analysisJson, dup.id);
}
// 选题提取:json 的 content 字段中「# 选题」章节 → account_videos.topic
const topic = extractTopic(jd.content);
if (topic) d.prepare(`UPDATE account_videos SET topic=? WHERE id=?`).run(topic, videoId);
} catch (e) { /* 补录失败不影响本次返回 */ }
return { found: true, content, fileName: jf };
}
} catch (e) { /* 单个 json 解析失败不影响后续匹配 */ }
}
}
}
return { found: false };
}
function readVideoParse(videoId) {
const d = initDb();
const v = d.prepare(`
SELECT v.*, h.source_dir AS acc_dir, h.account_name AS acc_name FROM account_videos v
LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.id=?
`).get(videoId);
if (!v) return { found: false };
const vt = String(v.video_title || "").trim();
const vn = vt ? normTitle(vt) : "";
const match = (title, dirName) => {
const t = String(title || "").trim();
if (!t) return false;
if (vt && t === vt) return true;
const tn = normTitle(t);
if (tn && vn && tn === vn) return true;
if (tn && vn && tn.length >= 6 && vn.length >= 6 && (tn.includes(vn) || vn.includes(tn))) return true;
if (tn && vn && vn.length >= 6) {
const ratio = 1 - titleDistance(tn, vn) / Math.max(tn.length, vn.length, 1);
if (ratio > 0.8) return true;
}
const first = String(dirName || "").split(/[__]/)[0].trim();
if (first && normTitle(first) && normTitle(first) === vn) return true;
return false;
};
const bases = [...resolveAccountDirs({ source_dir: v.acc_dir, account_name: v.acc_name }), v.source_dir]
.filter((x) => typeof x === "string" && x);
for (const base of bases) {
const vsDir = join(base, "视频对标");
if (!existsSync(vsDir)) continue;
for (const entry of readdirSync(vsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const vDir = join(vsDir, entry.name);
const jp = join(vDir, "content.json");
if (!existsSync(jp)) continue;
try {
const jd = JSON.parse(readFileSync(jp, "utf8"));
if (match(jd.title, entry.name)) {
const out = { found: true, content: typeof jd.content === "string" ? jd.content : null, analysis: null, fileName: "content.json" };
const ap = join(vDir, "analysis.json");
if (existsSync(ap)) {
try { out.analysis = JSON.parse(readFileSync(ap, "utf8")); } catch (e) { /* analysis 解析失败不阻塞 */ }
}
return out;
}
} catch (e) { /* 单个 json 解析失败不影响后续匹配 */ }
}
}
// 文件未命中时回退查库(content_json = content.json 全文取 content 字段;analysis_json 为 analysis.json 全文)
const row = d.prepare(
`SELECT content_json, analysis_json FROM account_video_source WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?) ORDER BY id DESC LIMIT 1`
).get(videoId, v.aweme_id);
if (row && row.content_json) {
try {
const jd = JSON.parse(row.content_json);
if (jd && typeof jd.content === "string") {
let analysis = null;
if (row.analysis_json) {
try { analysis = JSON.parse(row.analysis_json); } catch (e) { /* analysis 解析失败忽略 */ }
}
return { found: true, content: jd.content, analysis, fileName: "db" };
}
} catch (e) { /* 库内解析失败返回未找到 */ }
}
return { found: false };
}
function backfillVideoSource() {
const d = initDb();
let added = 0;
const accounts = d.prepare(`SELECT id, account_name, source_dir FROM hot_accounts WHERE del_flag=0`).all();
for (const acc of accounts) {
for (const base of resolveAccountDirs(acc)) {
const vsDir = join(base, "视频对标");
if (!existsSync(vsDir)) continue;
for (const entry of readdirSync(vsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const vDir = join(vsDir, entry.name);
const vid = findVideoByFolder(d, acc.id, vDir, entry.name);
// analysis.json 补录(与 content 同视频关联;幂等)
const ap = join(vDir, "analysis.json");
let analysisJson = null;
if (existsSync(ap)) {
try { analysisJson = readFileSync(ap, "utf8"); } catch (e) { /* 读取失败忽略 */ }
}
for (const jf of ["原视频解析.json", "content.json"]) {
const jp = join(vDir, jf);
if (!existsSync(jp)) continue;
try {
const content = readFileSync(jp, "utf8");
// 去重:用 IS 运算符,video_id/aweme_id 为 NULL 时也能正确去重(避免匹配不到视频时每次重复插入)
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id IS ? OR aweme_id IS ?) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
if (!dup) {
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary, analysis_json) VALUES (?,?,?,?,?,?)`)
.run(vid ? vid.id : null, vid ? vid.aweme_id : null, statSync(jp).mtime.toISOString(), content, "", analysisJson);
added++;
} else if (analysisJson && !(d.prepare(`SELECT analysis_json FROM account_video_source WHERE id=?`).get(dup.id)?.analysis_json)) {
d.prepare(`UPDATE account_video_source SET analysis_json=? WHERE id=?`).run(analysisJson, dup.id);
}
// 选题提取:json 的 content 字段中「# 选题」章节 → account_videos.topic
if (vid) {
try {
const jd = JSON.parse(content);
const topic = extractTopic(jd.content);
if (topic) d.prepare(`UPDATE account_videos SET topic=? WHERE id=?`).run(topic, vid.id);
} catch (e) { /* 单个 json 解析失败不影响 */ }
}
} catch (e) { /* 单个 json 失败不影响后续 */ }
}
}
}
}
return added;
}
function importExternalDir(dir) {
const d = initDb();
let accounts = 0, videos = 0, analyses = 0;
if (!existsSync(dir)) throw new Error(`目录不存在: ${dir}`);
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const accountDir = join(dir, entry.name);
let accountId = null;
const infoFile = join(accountDir, "账号信息.json");
if (existsSync(infoFile)) {
try {
const info = JSON.parse(readFileSync(infoFile, "utf8"));
accountId = upsertExternalAccount(d, info, accountDir);
accounts++;
} catch (e) {
console.warn(`[dsh-plugin-mcn] 账号信息解析失败 ${infoFile}: ${e.message}`);
}
}
if (accountId == null) continue;
// 视频列表(xlsx)
for (const f of readdirSync(accountDir)) {
if (/\.xlsx?$/i.test(f)) {
videos += importVideosFromXlsx(d, join(accountDir, f), accountId, accountDir);
}
}
// 账号设定/账号数据分析 md → account_persona / account_analysis
// 文件名可能带账号名前缀(如「王微斯账号设定.md」),用包含匹配;同账号+同内容去重
for (const md of readdirSync(accountDir)) {
const isPersona = /账号设定.*\.md$/i.test(md);
const isAnalysis = /账号数据分析.*\.md$/i.test(md);
if (!isPersona && !isAnalysis) continue;
const p = join(accountDir, md);
const content = readFileSync(p, "utf8");
const time = statSync(p).mtime.toISOString();
if (isPersona) {
const dup = d.prepare(`SELECT id FROM account_persona WHERE account_id=? AND content_json=?`).get(accountId, content);
if (!dup) {
d.prepare(`INSERT INTO account_persona (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accountId, time, content, "");
analyses++;
}
} else {
const dup = d.prepare(`SELECT id FROM account_analysis WHERE account_id=? AND content_json=?`).get(accountId, content);
if (!dup) {
d.prepare(`INSERT INTO account_analysis (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accountId, time, content, "");
analyses++;
}
}
}
// 视频对标/<视频名>/*拆解分析.md → account_video_analysis(同视频+同内容去重)
const vsDir = join(accountDir, "视频对标");
if (existsSync(vsDir)) {
for (const v of readdirSync(vsDir, { withFileTypes: true })) {
if (!v.isDirectory()) continue;
const vDir = join(vsDir, v.name);
for (const mdf of readdirSync(vDir)) {
if (!/拆解分析.*\.md$/i.test(mdf)) continue;
const mp = join(vDir, mdf);
const content = readFileSync(mp, "utf8");
const time = statSync(mp).mtime.toISOString();
const vid = findVideoByFolder(d, accountId, vDir, v.name);
const awemeId = vid ? vid.aweme_id ?? null : null;
const dup = d.prepare(`SELECT id FROM account_video_analysis WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, awemeId, content);
if (!dup) {
d.prepare(`INSERT INTO account_video_analysis (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`)
.run(vid ? vid.id : null, awemeId, time, content, "");
analyses++;
}
}
// 原视频解析 json → account_video_source(优先原视频解析.json,无则 content.json;同视频+同内容去重)
for (const jf of ["原视频解析.json", "content.json"]) {
const jp = join(vDir, jf);
if (!existsSync(jp)) continue;
try {
const content = readFileSync(jp, "utf8");
const time = statSync(jp).mtime.toISOString();
const vid = findVideoByFolder(d, accountId, vDir, v.name);
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
if (!dup) {
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`)
.run(vid ? vid.id : null, vid ? vid.aweme_id : null, time, content, "");
analyses++;
}
// 选题提取:json 的 content 字段中「# 选题」章节,单独存 account_videos.topic(对标创作选题素材)
if (vid) {
try {
const jd = JSON.parse(content);
const topic = extractTopic(jd.content);
if (topic) {
d.prepare(`UPDATE account_videos SET topic=? WHERE id=?`).run(topic, vid.id);
}
} catch (e) { /* 单个 json 解析失败不影响 */ }
}
} catch (e) { /* 单个 json 解析失败不影响后续导入 */ }
}
}
}
}
return { accounts, videos, analyses };
}
function importExternalData(d, data) {
let accounts = 0, videos = 0, analyses = 0;
const accCache = {};
const findAccount = (name) => {
if (!name) return null;
if (accCache[name]) return accCache[name];
const row = d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND account_name=?`).get(name);
accCache[name] = row ? row.id : null;
return accCache[name];
};
const now = new Date().toISOString();
// 账号
for (const a of data.accounts || []) {
const id = upsertExternalAccount(d, a, a.source_dir || "");
if (a.account_name) accCache[a.account_name] = id;
accounts++;
}
// 视频
// upsert:保留行 id(REPLACE 会删旧插新导致 id 漂移,拆解分析 video_id 关联断裂)
const insV = d.prepare(`INSERT INTO account_videos
(account_id, aweme_id, video_title, video_url, like_count, like_display, comment_count, share_count, collect_count, play_count, duration, publish_time, tags, collected_time, source_dir, topic)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(account_id, aweme_id) DO UPDATE SET
video_title=excluded.video_title, video_url=excluded.video_url,
like_count=excluded.like_count, like_display=excluded.like_display,
comment_count=excluded.comment_count, share_count=excluded.share_count,
collect_count=excluded.collect_count, play_count=excluded.play_count,
duration=excluded.duration, publish_time=excluded.publish_time,
tags=excluded.tags, topic=excluded.topic,
collected_time=excluded.collected_time, source_dir=excluded.source_dir`);
for (const v of data.videos || []) {
const accId = v.account_id != null ? Number(v.account_id) : findAccount(v.account_name);
if (!accId || v.aweme_id == null) continue;
const tags = Array.isArray(v.tags) ? v.tags.join(",") : (v.tags != null ? String(v.tags) : "");
// 选题:优先显式 topic 字段;未提供时尝试从 source 解析提取(兼容 AI 直接传解析 content)
let topic = v.topic != null ? String(v.topic) : "";
if (!topic && (v.source_content || v.source_json)) {
try {
const jd = typeof v.source_json === "string" ? JSON.parse(v.source_json) : v.source_json;
topic = extractTopic(jd?.content || v.source_content);
} catch (e) { /* 提取失败回退空 */ }
}
insV.run(accId, String(v.aweme_id),
v.video_title != null ? String(v.video_title) : "",
v.video_url != null ? String(v.video_url) : "",
num(v.like_count), v.like_display != null ? String(v.like_display) : "",
num(v.comment_count), num(v.share_count), num(v.collect_count), num(v.play_count),
v.duration != null ? String(v.duration) : "",
v.publish_time != null ? String(v.publish_time) : "",
tags, now, v.source_dir || "", topic);
videos++;
}
// 分析(persona / account / video,内容去重)
for (const an of data.analyses || []) {
const accId = an.account_id != null ? Number(an.account_id) : findAccount(an.account_name);
if (!accId || !an.content) continue;
const time = an.analysis_time || now;
const summary = an.summary || "";
if (an.type === "persona") {
const dup = d.prepare(`SELECT id FROM account_persona WHERE account_id=? AND content_json=?`).get(accId, an.content);
if (!dup) { d.prepare(`INSERT INTO account_persona (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accId, time, an.content, summary); analyses++; }
} else if (an.type === "account") {
const dup = d.prepare(`SELECT id FROM account_analysis WHERE account_id=? AND content_json=?`).get(accId, an.content);
if (!dup) { d.prepare(`INSERT INTO account_analysis (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accId, time, an.content, summary); analyses++; }
} else if (an.type === "video") {
let vid = null;
if (an.video_aweme_id != null) {
vid = d.prepare(`SELECT id FROM account_videos WHERE account_id=? AND aweme_id=?`).get(accId, String(an.video_aweme_id));
}
const dup = d.prepare(`SELECT id FROM account_video_analysis WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, an.video_aweme_id != null ? String(an.video_aweme_id) : null, an.content);
if (!dup) {
// 删旧保新:同一视频只保留最新一份解析(重新解析后旧版本删除)
d.prepare(`DELETE FROM account_video_analysis WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)`).run(vid ? vid.id : null, an.video_aweme_id != null ? String(an.video_aweme_id) : null);
d.prepare(`INSERT INTO account_video_analysis (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`).run(vid ? vid.id : null, an.video_aweme_id != null ? String(an.video_aweme_id) : null, time, an.content, summary);
analyses++;
}
} else if (an.type === "source") {
// 原视频解析(原视频解析.json / content.json 内容)
let vid = null;
if (an.video_aweme_id != null) {
vid = d.prepare(`SELECT id, aweme_id FROM account_videos WHERE account_id=? AND aweme_id=?`).get(accId, String(an.video_aweme_id));
}
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, an.content);
if (!dup) {
// 删旧保新:同一视频只保留最新一份源脚本解析(重新解析后旧版本删除)
d.prepare(`DELETE FROM account_video_source WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)`).run(vid ? vid.id : null, vid ? vid.aweme_id : null);
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`).run(vid ? vid.id : null, vid ? vid.aweme_id : null, time, an.content, summary);
analyses++;
}
}
}
return { accounts, videos, analyses };
}
function previewExternalDir(dir) {
const d = initDb();
const result = {
accounts: { new: [], update: [], same: [] },
videos: { new: [], same: [] },
analyses: { new: [], duplicate: [] },
accountsTotal: 0, videosTotal: 0, analysesTotal: 0,
};
if (!existsSync(dir)) throw new Error(`目录不存在: ${dir}`);
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const accountDir = join(dir, entry.name);
let accountId = null;
let hasAccountInfo = false;
const infoFile = join(accountDir, "账号信息.json");
if (existsSync(infoFile)) {
try {
const info = JSON.parse(readFileSync(infoFile, "utf8"));
const name = info.nickname || entry.name;
const did = info.douyin_id != null ? String(info.douyin_id) : null;
const existing = did
? d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND douyin_id=?`).get(did)
: d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND account_name=?`).get(name);
if (!existing) {
result.accounts.new.push(name);
} else {
accountId = existing.id;
const row = d.prepare(`SELECT bio, followers FROM hot_accounts WHERE id=?`).get(existing.id);
const changed = (row?.bio || "") !== (info.bio || "")
|| (row?.followers || "") !== String(info.followers ?? "");
if (changed) result.accounts.update.push(name);
else result.accounts.same.push(name);
}
hasAccountInfo = true;
result.accountsTotal++;
} catch (e) {
console.warn(`[dsh-plugin-mcn] 账号信息解析失败 ${infoFile}: ${e.message}`);
}
}
// 无账号信息的目录不参与比对;新增账号(accountId 为 null)仍扫描视频/分析,全部标记为新增
if (!hasAccountInfo) continue;
// 视频
for (const f of readdirSync(accountDir)) {
if (!/\.xlsx?$/i.test(f)) continue;
const wb = XLSX.readFile(join(accountDir, f));
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
if (!rows.length) continue;
const header = rows[0].map((h) => String(h ?? "").trim().toLowerCase());
const iId = header.findIndex((h) => ["视频id", "aweme_id", "视频id(显示)"].includes(h));
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
if (!row || row[iId] == null) continue;
const aweme = String(row[iId]);
const ex = accountId != null
? d.prepare(`SELECT id FROM account_videos WHERE account_id=? AND aweme_id=?`).get(accountId, aweme)
: undefined;
if (!ex) result.videos.new.push(aweme);
else result.videos.same.push(aweme);
result.videosTotal++;
}
}
// md 分析(文件名可带账号名前缀,内容存在=重复)
const checkMd = (mdPattern, table) => {
for (const f of readdirSync(accountDir)) {
if (!mdPattern.test(f)) continue;
const p = join(accountDir, f);
const content = readFileSync(p, "utf8");
const ex = d.prepare(`SELECT id FROM ${table} WHERE account_id=? AND content_json=?`).get(accountId, content);
if (!ex) result.analyses.new.push(f);
else result.analyses.duplicate.push(f);
result.analysesTotal++;
}
};
checkMd(/账号设定.*\.md$/i, "account_persona");
checkMd(/账号数据分析.*\.md$/i, "account_analysis");
// 视频拆解
const vsDir = join(accountDir, "视频对标");
if (existsSync(vsDir)) {
for (const v of readdirSync(vsDir, { withFileTypes: true })) {
if (!v.isDirectory()) continue;
const vDir = join(vsDir, v.name);
for (const mdf of readdirSync(vDir)) {
if (!/拆解分析.*\.md$/i.test(mdf)) continue;
const content = readFileSync(join(vDir, mdf), "utf8");
const vid = findVideoByFolder(d, accountId, vDir, v.name);
const ex = d.prepare(`SELECT id FROM account_video_analysis WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
if (!ex) result.analyses.new.push(v.name);
else result.analyses.duplicate.push(v.name);
result.analysesTotal++;
}
// 原视频解析 json(优先原视频解析.json,无则 content.json)
for (const jf of ["原视频解析.json", "content.json"]) {
const jp = join(vDir, jf);
if (!existsSync(jp)) continue;
const content = readFileSync(jp, "utf8");
const vid = findVideoByFolder(d, accountId, vDir, v.name);
const ex = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
if (!ex) result.analyses.new.push(v.name + " / " + jf);
else result.analyses.duplicate.push(v.name + " / " + jf);
result.analysesTotal++;
}
}
}
}
return result;
}
export {
isInternalAccount, upsertExternalAccount, importVideosFromXlsx,
readVideoSource, readVideoParse, backfillVideoSource,
importExternalDir, importExternalData, previewExternalDir,
};
File diff suppressed because it is too large. Load diff
+61
View File
@@ -0,0 +1,61 @@
// dsh-plugin-mcn MCP stdio 客户端(复用已验证方案;拆分自 index.js)
import { homedir } from "node:os";
import { join, isAbsolute, resolve } from "node:path";
import { spawn } from "node:child_process";
import { NPX_CMD, MCP_PACKAGE, MCP_ENV } from "./config.js";
function dshHome() {
const configured = process.env.DSH_HOME?.trim();
if (!configured) return join(homedir(), ".dsh");
return isAbsolute(configured) ? configured : resolve(configured);
}
function callMcpTool(toolName, args = {}) {
return new Promise((resolvePromise, reject) => {
const reqs = [
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "dsh-plugin-mcn", version: "0.1.0" } } }),
JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } }),
].join("\n") + "\n";
const child = spawn(NPX_CMD, ["-y", MCP_PACKAGE], {
env: { ...process.env, ...MCP_ENV },
shell: true,
stdio: ["pipe", "pipe", "inherit"],
});
let stdout = "";
let settled = false;
const timer = setTimeout(() => {
if (!settled) { settled = true; child.kill(); reject(new Error("MCP 调用超时")); }
}, 120000);
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
// 找到 id=2 的响应
const lines = stdout.split("\n");
for (const line of lines) {
if (!line.includes('"id":2')) continue;
const msg = JSON.parse(line);
if (msg.result?.isError) { reject(new Error(msg.result.content?.[0]?.text || "MCP 工具报错")); return; }
// 优先 structuredContent(结构化数据);无则回退 text(格式化文本)
const sc = msg.result?.structuredContent;
const text = msg.result?.content?.[0]?.text ?? "";
resolvePromise({ structured: sc ?? null, text });
return;
}
reject(new Error("未找到 MCP 工具响应"));
} catch (e) {
reject(new Error("解析 MCP 响应失败: " + e.message));
}
});
child.stdin.write(reqs);
child.stdin.end();
child.on("error", (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(new Error("MCP 进程启动失败: " + e.message)); } });
});
}
export { dshHome, callMcpTool };
+107
View File
@@ -0,0 +1,107 @@
// dsh-plugin-mcn 视频/账号匹配工具(纯函数,拆分自 index.js)
import { homedir } from "node:os";
import { join } from "node:path";
import { readFileSync, existsSync } from "node:fs";
function normTitle(s) {
return String(s || "").toLowerCase().replace(/[\s#,。!?、,.!?·\-_~~::"“”()()【】]+/g, "");
}
function extractTopic(content) {
const s = String(content || "");
if (!s.trim()) return "";
const lines = s.replace(/\r\n/g, "\n").split("\n");
let start = -1;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (/^#{1,4}\s*选题\s*$/.test(t)) { start = i + 1; break; }
}
if (start < 0) return "";
const out = [];
for (let i = start; i < lines.length; i++) {
const t = lines[i].trim();
if (/^#{1,6}\s/.test(t) && !/^#{1,6}\s*选题\s*$/.test(t)) break;
if (t) out.push(lines[i].trim());
}
return out.join("\n").trim();
}
function titleDistance(a, b) {
const m = a.length, n = b.length;
if (!m) return n;
if (!n) return m;
let prev = new Array(n + 1), curr = new Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++) {
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
}
[prev, curr] = [curr, prev];
}
return prev[n];
}
function findVideoByFolder(d, accountId, vDir, vName) {
const rows = d.prepare(`SELECT id, aweme_id, video_title FROM account_videos WHERE account_id=?`).all(accountId);
const pick = (title) => {
const t = String(title || "").trim();
if (!t) return undefined;
const exact = rows.find((r) => String(r.video_title || "") === t);
if (exact) return exact;
const tn = normTitle(t);
if (!tn) return undefined;
let best = null;
for (const r of rows) {
const cn = normTitle(r.video_title);
if (!cn || cn.length < 6 || tn.length < 6) continue;
if (tn.includes(cn) || cn.includes(tn)) {
if (!best || tn.length > best.len) best = { id: r.id, aweme_id: r.aweme_id, len: tn.length };
}
}
if (best) return best;
let bestSim = null;
for (const r of rows) {
const cn = normTitle(r.video_title);
if (!cn) continue;
const ratio = 1 - titleDistance(tn, cn) / Math.max(tn.length, cn.length, 1);
if (ratio > 0.8 && (!bestSim || ratio > bestSim.ratio)) bestSim = { id: r.id, aweme_id: r.aweme_id, ratio };
}
return bestSim ? bestSim : undefined;
};
for (const jf of ["原视频解析.json", "content.json"]) {
const jp = join(vDir, jf);
if (!existsSync(jp)) continue;
try {
const jd = JSON.parse(readFileSync(jp, "utf8"));
const hit = pick(jd.title);
if (hit) return hit;
} catch (e) { /* 单个 json 解析失败不影响后续匹配 */ }
}
const first = String(vName || "").split("_")[0].trim();
if (first) {
const hit = pick(first);
if (hit) return hit;
}
return undefined;
}
function resolveAccountDirs(acc) {
const out = [];
if (acc && typeof acc.source_dir === "string" && acc.source_dir && existsSync(acc.source_dir)) out.push(acc.source_dir);
const legacy = acc && acc.account_name ? join(homedir(), "Desktop", "账号对标", acc.account_name) : null;
if (legacy && existsSync(legacy)) out.push(legacy);
return out;
}
function resolveAccountDir(acc) {
return resolveAccountDirs(acc)[0] || null;
}
/** 数值化(非法输入返回 0) */
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
export { num, normTitle, extractTopic, titleDistance, findVideoByFolder, resolveAccountDirs, resolveAccountDir };
+303
View File
@@ -0,0 +1,303 @@
// dsh-plugin-mcn 工作台数据访问(V1.0 同步增量):榜单四榜解析 / 原创选题脚本 / 批量删除连坐
// 语义同步自 V1.0 mcn-work-shop(dsh-data.js / server.js 09-03~09-04 定稿),纯函数便于离线冒烟测试。
// 约定:db 参数由调用方注入(node:sqlite DatabaseSync);目录参数显式传入(榜单目录由路由解析)。
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
/* ================= 榜单目录与文件族(09-03 分目录管理;根目录旧平铺兼容) =================
* 抖音榜单/
* 账号周榜/抖音周榜_{赛道}_{周一}.json → week(每赛道一期一文件,douyin-top-account 落盘)
* 账号日榜/抖音账号日榜_{日期}.json → day(全品类单文件,fetch_rank.py --period day 落盘)
* 视频热榜/抖音周榜_视频热榜_{周一}.json → hot(全品类单文件,fetch_week_ranks.py --board both 落盘)
* 点赞榜/ 抖音周榜_点赞榜_{周一}.json → likes(同上)
* 全品类单文件无赛道段:赛道在 list[].category,赛道下拉/筛选按条目 category 匹配。
*/
export const RANK_SUB = { week: "账号周榜", day: "账号日榜", hot: "视频热榜", likes: "点赞榜" };
export const RANK_FILE_RE = {
week: /^抖音周榜_(.+)_(\d{4}-\d{2}-\d{2})\.json$/,
day: /^抖音账号日榜_(\d{4}-\d{2}-\d{2})\.json$/,
hot: /^抖音周榜_视频热榜_(\d{4}-\d{2}-\d{2})\.json$/,
likes: /^抖音周榜_点赞榜_(\d{4}-\d{2}-\d{2})\.json$/,
};
// 工作台所需赛道白名单(与账号周榜 douyin-top-account 保留 5 赛道同口径;账号榜 type=「生活vlog」、
// 作品榜条目 category=「生活记录」→ 并集放行);生效范围 day/hot/likes;week 文件名赛道天然受限。
export const RANK_KEEP = ["生活记录", "生活vlog", "小剧场", "亲子", "美食", "旅行"];
const RANK_KEEP_SET = new Set(RANK_KEEP);
// 默认排序键随榜(同分按排名升序)
export const RANK_DEF_KEY = {
week: "comprehensiveScore",
day: "comprehensiveScore",
hot: "likeCount",
likes: "add_digg_count",
};
export function parseNum(v) {
const s = String(v ?? "");
const m = s.match(/^([\d.]+)\s*(w|万)?$/i);
if (!m) return 0;
let n = parseFloat(m[1]);
if (m[2]) n *= 10000;
return n;
}
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function parseRankFileIn(board, dir, name) {
const m = String(name).match(RANK_FILE_RE[board]);
if (!m) return null;
if (board === "week") return { board, dir, name, category: m[1], date: m[2] };
return { board, dir, name, category: "全部", date: m[1] };
}
/** 枚举某榜全部数据文件(新子目录结构 + 根目录旧平铺兼容),按日期倒序 */
export function listRankFiles(dir, board) {
if (!dir || !existsSync(dir)) return [];
const out = [];
const subDir = join(dir, RANK_SUB[board]);
if (existsSync(subDir)) {
for (const f of readdirSync(subDir)) {
const p = parseRankFileIn(board, RANK_SUB[board], f);
if (p) out.push(p);
}
}
for (const f of readdirSync(dir)) {
const p = parseRankFileIn(board, "", f);
if (p) out.push(p);
}
return out.sort((a, b) => b.date.localeCompare(a.date));
}
function rankFilePath(dir, sub, name) {
return sub ? join(dir, sub, name) : join(dir, name);
}
/** 赛道下拉:白名单内且当期数据实际出现者,按 RANK_KEEP 顺序输出;week=文件名赛道,其余=条目 category 并集 */
export function collectRankCategories(dir, board, files) {
const found = new Set();
if (board === "week") {
for (const f of files) found.add(f.category);
} else {
for (const pf of files) {
try {
const d = JSON.parse(readFileSync(rankFilePath(dir, pf.dir, pf.name), "utf8"));
for (const it of (d.list || [])) {
const c = String(it.category || "").trim();
if (c) found.add(c);
}
} catch (e) { /* 单个文件损坏跳过 */ }
}
}
return RANK_KEEP.filter((c) => found.has(c));
}
/** 榜单新鲜度:递归扫描目录全部 .json 的最新 mtime(=最近一次成功落盘时刻) */
export function rankingFreshness(dir) {
const out = { exists: !!dir && existsSync(dir), latestMtime: 0, latestFile: "", latestDate: "", files: 0 };
if (!out.exists) return out;
const walk = (d) => {
let entries = [];
try { entries = readdirSync(d, { withFileTypes: true }); } catch (e) { return; }
for (const en of entries) {
const full = join(d, en.name);
if (en.isDirectory()) walk(full);
else if (/\.json$/i.test(en.name)) {
out.files++;
try {
const st = statSync(full);
if (st.mtimeMs > out.latestMtime) { out.latestMtime = st.mtimeMs; out.latestFile = full; }
} catch (e) { /* 忽略 */ }
}
}
};
walk(dir);
const m = String(out.latestFile).match(/(\d{4}-\d{2}-\d{2})\.json$/);
if (m) out.latestDate = m[1];
return out;
}
/** 四榜查询(week=账号周榜 / day=账号日榜 / hot=视频热榜 / likes=点赞榜) */
export function getRanking({ dir, board = "week", date = "", category = "", sortBy = "", order = "", page = 1, pageSize = 20, db = null } = {}) {
const empty = { dates: [], categories: [], category: "", date: "", total: 0, items: [] };
const files = listRankFiles(dir, board);
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.date))];
const categories = collectRankCategories(dir, board, files);
const selDate = date || dates[0];
// week:按文件名赛道过滤(每赛道一文件);全品类单文件:赛道过滤在条目层
const selFiles = files.filter((f) => f.date === selDate && (board !== "week" || !category || f.category === category));
let items = [];
for (const pf of selFiles) {
try {
const d = JSON.parse(readFileSync(rankFilePath(dir, pf.dir, pf.name), "utf8"));
items = items.concat(d.list || []);
} catch (e) { /* 单个文件损坏跳过 */ }
}
// 全品类单文件(day/hot/likes):①白名单过滤(含「全部」)②按请求赛道条目过滤
if (board !== "week") {
items = items.filter((it) => RANK_KEEP_SET.has(String(it.category || "").trim()));
if (category && category !== "全部") {
items = items.filter((it) => String(it.category || "").trim() === category);
}
}
const defKey = RANK_DEF_KEY[board];
if (!order && (!sortBy || sortBy === defKey)) {
items.sort((a, b) => {
const va = num(a[defKey]), vb = num(b[defKey]);
if (va !== vb) return vb - va;
return num(a.rank) - num(b.rank);
});
} else {
const key = sortBy || defKey;
const dir2 = order === "asc" ? "asc" : (order === "desc" ? "desc" : (key === "rank" ? "asc" : "desc"));
items.sort((a, b) => {
const va = key === "rank" ? num(a.rank) : parseNum(a[key]);
const vb = key === "rank" ? num(b.rank) : parseNum(b[key]);
return dir2 === "asc" ? va - vb : vb - va;
});
}
const total = items.length;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const secOf = (url) => { const m = String(url || "").match(/\/user\/([^/?#]+)/); return m ? m[1] : ""; };
let lookup = null;
if (db) {
try { lookup = db.prepare(`SELECT id FROM hot_accounts WHERE (sec_uid = ? OR account_name = ?) AND (del_flag IS NULL OR del_flag <> 1) LIMIT 1`); } catch (e) { lookup = null; }
}
const pageItems = items.slice(offset, offset + limit).map((row) => {
let hit = null;
const sec = row.profileUrl ? secOf(row.profileUrl) : "";
if (lookup) {
try { hit = sec ? lookup.get(sec, row.accountName) : null; } catch (e) { hit = null; }
}
return { ...row, inDb: !!hit, dbId: hit ? hit.id : null };
});
return { dates, categories, category, date: selDate, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: pageItems };
}
/* ================= 原创选题(无视频载体)脚本与统计(同步 V1.0 v25:video_id 空 = 原创) ================= */
function daysAgoIso(n) {
try {
return new Date(Date.now() - Number(n) * 86400000).toISOString();
} catch (e) {
return "";
}
}
export function listCustomScripts(d, { page = 1, pageSize = 20, search = "", days = "", account = "" } = {}) {
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = ["(r.video_id IS NULL OR r.video_id = 0)", "r.status='done'", "r.script_text IS NOT NULL", "r.script_text <> ''"];
const params = [];
if (search) { where.push("(r.title LIKE ? OR h.account_name LIKE ? OR r.topic LIKE ?)"); params.push(`%${search}%`, `%${search}%`, `%${search}%`); }
if (account) { where.push("h.account_name = ?"); params.push(account); }
const n = Number(days);
if (n > 0) { const cutoff = daysAgoIso(n); if (cutoff) { where.push("r.created_time >= ?"); params.push(cutoff); } }
const w = "WHERE " + where.join(" AND ");
const total = d.prepare(`SELECT COUNT(*) c FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id ${w}`).get(...params).c;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const items = d.prepare(`
SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.gen_method, r.status, r.created_time, h.account_name,
length(r.script_text) AS script_len
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id
${w} ORDER BY r.id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
/** 原创选题脚本全文(查看弹窗用,避免列表携带大文本);非原创返回 null */
export function getCustomScriptRow(d, id) {
if (!d) throw new Error("数据库不可用");
const row = d.prepare(`SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.script_text, r.status, r.created_time, r.updated_time, h.account_name
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id WHERE r.id=?`).get(Number(id));
if (!row || row.video_id) return null;
return row;
}
/** 三 tab 徽标计数(与账号列表「AI脚本」列同口径:done 且脚本非空);account=账号名时按账号统计 */
export function countRewriteStats(d, account = "") {
if (!d) return { videoScripts: 0, customScripts: 0 };
const conds = ["r.status='done'", "r.script_text IS NOT NULL", "r.script_text <> ''"];
const params = [];
if (account) { conds.push("h.account_name = ?"); params.push(account); }
const base = `FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id WHERE ${conds.join(" AND ")}`;
const videoScripts = d.prepare(`SELECT COUNT(*) c ${base} AND (r.video_id IS NOT NULL AND r.video_id > 0)`).get(...params).c;
const customScripts = d.prepare(`SELECT COUNT(*) c ${base} AND (r.video_id IS NULL OR r.video_id = 0)`).get(...params).c;
return { videoScripts, customScripts };
}
/* ================= 批量删除(连坐,同步 V1.0 09-04):账号=软删+连坐;视频/脚本=物理删视频行+连坐 =================
* kind:accounts(软删账号 + 连坐 persona/analysis/videos 及其 source/analysis/rewrite/复盘/分镜 + 原创脚本)
* videos | rewrites(删视频行 + 同连坐;「rewrites」为 V1.0 AI写脚本页批量删除口径=删视频实体)
* 返回 { deleted, rewriteIds[], storyboardIds[] }(rewriteIds/storyboardIds 供路由同步清理会话目录,防 id 复用污染)
*/
export function batchDeleteCascade(d, kind, ids) {
const clean = (Array.isArray(ids) ? ids : []).map(Number).filter((x) => Number.isFinite(x) && x > 0);
if (!d || clean.length === 0) return { deleted: 0, rewriteIds: [], storyboardIds: [] };
const q = clean.map(() => "?").join(",");
const rewriteIds = [];
const storyboardIds = [];
/** 删除单条脚本及关联(ai/compare 复盘 + 分镜;源视频级复盘由调用方按 video 处理) */
const cascadeRewrite = (rwId) => {
rewriteIds.push(rwId);
try { d.prepare(`DELETE FROM script_review WHERE rewrite_id=? AND type IN ('ai','compare')`).run(rwId); } catch (e) {}
try {
const sbs = d.prepare(`SELECT id FROM storyboard_log WHERE rewrite_id=?`).all(rwId).map((x) => x.id);
storyboardIds.push(...sbs);
d.prepare(`DELETE FROM storyboard_log WHERE rewrite_id=?`).run(rwId);
} catch (e) {}
try { d.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(rwId); } catch (e) {}
};
/** 深删视频行(按 id/aweme_id 双键清 source/analysis + 该视频全部脚本连坐 + 源视频级复盘) */
const deepVideos = (rows) => {
for (const v of rows) {
try {
if (v.aweme_id) {
d.prepare(`DELETE FROM account_video_source WHERE aweme_id=?`).run(v.aweme_id);
d.prepare(`DELETE FROM account_video_analysis WHERE aweme_id=?`).run(v.aweme_id);
}
d.prepare(`DELETE FROM account_video_source WHERE video_id=?`).run(v.id);
d.prepare(`DELETE FROM account_video_analysis WHERE video_id=?`).run(v.id);
d.prepare(`DELETE FROM script_review WHERE type='source' AND video_id=?`).run(v.id);
} catch (e) {}
try {
const rws = d.prepare(`SELECT id FROM rewrite_log WHERE video_id=?`).all(v.id);
for (const r of rws) cascadeRewrite(r.id);
} catch (e) {}
try { d.prepare(`DELETE FROM account_videos WHERE id=?`).run(v.id); } catch (e) {}
}
};
let deleted = 0;
try {
d.exec("BEGIN");
if (kind === "accounts") {
const accs = clean;
for (const accId of accs) {
try {
d.prepare(`DELETE FROM account_persona WHERE account_id=?`).run(accId);
d.prepare(`DELETE FROM account_analysis WHERE account_id=?`).run(accId);
} catch (e) {}
const videos = d.prepare(`SELECT id, aweme_id FROM account_videos WHERE account_id=?`).all(accId);
deepVideos(videos);
// 原创选题脚本(无视频载体,account_id 直挂账号)
try {
const originals = d.prepare(`SELECT id FROM rewrite_log WHERE account_id=? AND (video_id IS NULL OR video_id = 0)`).all(accId);
for (const r of originals) cascadeRewrite(r.id);
} catch (e) {}
const r = d.prepare(`UPDATE hot_accounts SET del_flag=1 WHERE id=? AND (del_flag IS NULL OR del_flag <> 1)`).run(accId);
deleted += Number(r.changes);
}
} else {
// kind=videos | rewrites:删视频行(AI写脚本页 ref 列表批量=删除整行视频载体)
const rows = d.prepare(`SELECT id, aweme_id FROM account_videos WHERE id IN (${q})`).all(...clean);
deepVideos(rows);
deleted += rows.length;
}
d.exec("COMMIT");
} catch (e) {
try { d.exec("ROLLBACK"); } catch (e2) {}
throw e;
}
return { deleted, rewriteIds, storyboardIds };
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "dsh-plugin-mcn",
"version": "0.1.0",
"description": "MCN短视频导航插件:左侧面板快速打开抖音官网(参考 deepseek-plugin-store 实现)",
"license": "MIT",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
}
},
"files": [
"lib",
"cordis.patch.yml",
"README.md"
],
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.5",
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.5",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.5"
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {
"dsh-plugin-mcn": "file:D:/dshworkspace/plugin_package/dsh-plugin-mcn",
"dsh-plugin-douyin-accounts": "file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-accounts",
"dsh-plugin-douyin-account-detail": "file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-account-detail",
"dsh-plugin-douyin-video-detail": "file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-video-detail",
"dsh-plugin-mcn-schedule": "file:D:/dshworkspace/plugin_package/dsh-plugin-mcn-schedule",
"dsh-plugin-social-workbench": "file:D:/dshworkspace/plugin_package/dsh-plugin-social-workbench",
"sharp": "^0.35.3",
"xlsx": "^0.18.5"
},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-plugin-mcn",
"dsh-plugin-douyin-accounts",
"dsh-plugin-douyin-account-detail",
"dsh-plugin-douyin-video-detail",
"dsh-plugin-mcn-schedule",
"dsh-plugin-social-workbench"
]
}
}
}
+520
View File
@@ -0,0 +1,520 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: false
excludeLinksFromLockfile: false
importers:
.:
dependencies:
dsh-plugin-douyin-account-detail:
specifier: file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-account-detail
version: file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-account-detail
dsh-plugin-douyin-accounts:
specifier: file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-accounts
version: file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-accounts
dsh-plugin-douyin-video-detail:
specifier: file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-video-detail
version: file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-video-detail
dsh-plugin-mcn:
specifier: file:D:/dshworkspace/plugin_package/dsh-plugin-mcn
version: file:D:/dshworkspace/plugin_package/dsh-plugin-mcn
dsh-plugin-mcn-schedule:
specifier: file:D:/dshworkspace/plugin_package/dsh-plugin-mcn-schedule
version: file:D:/dshworkspace/plugin_package/dsh-plugin-mcn-schedule
dsh-plugin-social-workbench:
specifier: file:D:/dshworkspace/plugin_package/dsh-plugin-social-workbench
version: file:D:/dshworkspace/plugin_package/dsh-plugin-social-workbench
sharp:
specifier: ^0.35.3
version: 0.35.3
xlsx:
specifier: ^0.18.5
version: 0.18.5
packages:
'@emnapi/[email protected]':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@img/[email protected]':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/[email protected]':
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/[email protected]':
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/[email protected]':
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/[email protected]':
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64]
os: [darwin]
'@img/[email protected]':
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64]
os: [darwin]
'@img/[email protected]':
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
engines: {node: '>=20.9.0'}
'@img/[email protected]':
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/[email protected]':
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/[email protected]':
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/[email protected]':
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
[email protected]:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
[email protected]:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
[email protected]:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
[email protected]:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dsh-plugin-douyin-account-detail@file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-account-detail:
resolution: {directory: D:/dshworkspace/plugin_package/dsh-plugin-douyin-account-detail, type: directory}
dsh-plugin-douyin-accounts@file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-accounts:
resolution: {directory: D:/dshworkspace/plugin_package/dsh-plugin-douyin-accounts, type: directory}
dsh-plugin-douyin-video-detail@file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-video-detail:
resolution: {directory: D:/dshworkspace/plugin_package/dsh-plugin-douyin-video-detail, type: directory}
dsh-plugin-mcn-schedule@file:D:/dshworkspace/plugin_package/dsh-plugin-mcn-schedule:
resolution: {directory: D:/dshworkspace/plugin_package/dsh-plugin-mcn-schedule, type: directory}
dsh-plugin-mcn@file:D:/dshworkspace/plugin_package/dsh-plugin-mcn:
resolution: {directory: D:/dshworkspace/plugin_package/dsh-plugin-mcn, type: directory}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-client-runtime': ^0.1.0-rc.5
'@deepseek-ai/dsh-client-ui-sidebar': ^0.1.0-rc.5
'@deepseek-ai/dsh-client-ui-slots': ^0.1.0-rc.5
dsh-plugin-social-workbench@file:D:/dshworkspace/plugin_package/dsh-plugin-social-workbench:
resolution: {directory: D:/dshworkspace/plugin_package/dsh-plugin-social-workbench, type: directory}
[email protected]:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
[email protected]:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
[email protected]:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
[email protected]:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
[email protected]:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected]:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
[email protected]:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
[email protected]:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
snapshots:
'@emnapi/[email protected]':
dependencies:
tslib: 2.8.1
optional: true
'@img/[email protected]': {}
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.2
optional: true
'@img/[email protected]':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true
'@img/[email protected]':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true
'@img/[email protected]':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/[email protected]':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
'@img/[email protected]':
optional: true
[email protected]: {}
[email protected]:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
dsh-plugin-douyin-account-detail@file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-account-detail: {}
dsh-plugin-douyin-accounts@file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-accounts: {}
dsh-plugin-douyin-video-detail@file:D:/dshworkspace/plugin_package/dsh-plugin-douyin-video-detail: {}
dsh-plugin-mcn-schedule@file:D:/dshworkspace/plugin_package/dsh-plugin-mcn-schedule: {}
dsh-plugin-mcn@file:D:/dshworkspace/plugin_package/dsh-plugin-mcn:
dependencies:
react: 18.3.1
react-dom: 18.3.1([email protected])
dsh-plugin-social-workbench@file:D:/dshworkspace/plugin_package/dsh-plugin-social-workbench: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
js-tokens: 4.0.0
[email protected]([email protected]):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
[email protected]:
dependencies:
loose-envify: 1.4.0
[email protected]:
dependencies:
loose-envify: 1.4.0
[email protected]: {}
[email protected]:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.3
'@img/sharp-darwin-x64': 0.35.3
'@img/sharp-freebsd-wasm32': 0.35.3
'@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-linux-arm': 0.35.3
'@img/sharp-linux-arm64': 0.35.3
'@img/sharp-linux-ppc64': 0.35.3
'@img/sharp-linux-riscv64': 0.35.3
'@img/sharp-linux-s390x': 0.35.3
'@img/sharp-linux-x64': 0.35.3
'@img/sharp-linuxmusl-arm64': 0.35.3
'@img/sharp-linuxmusl-x64': 0.35.3
'@img/sharp-webcontainers-wasm32': 0.35.3
'@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3
[email protected]:
dependencies:
frac: 1.1.2
[email protected]:
optional: true
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
+5
View File
@@ -0,0 +1,5 @@
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false