baseline: dsh 工作区基线快照(2026-09-10,全新历史起点)
本提交为 dsh_mcn_workshop 仓库的首个提交(旧仓 dsh_MCNProject 已停用), 完整固化 C:/Users/Administrator/.dsh 当前磁盘状态。 1) 临时数据清理:移出 sessions/(81M)、logs/、skill-backup-20260906/(14M)、 scripts/__pycache__、storages/workspace.json.bak —— 合计 94M(.dsh 162M → 68M) 2) 凭据脱敏:.credentials.yaml(含明文 DEEPSEEK_API_KEY)移出 git 跟踪并加入 .gitignore;mcn-data-insight/scripts/setup_redfox_key.py 中真实 key 示例改为占位符 3) .gitignore 增补 logs/、skill-backup-*/ 规则 4) 技能结构以当前磁盘状态为准:skills/ = mcn-short-video / impeccable / taste-skill / dsh-multi-user-migration;旧技能目录(mcn-dou-analysis、storyboard-prompt、 short-video-script、browser-harness、mcn-data-insight)与 profiles/web/node_modules/dsh-vision-router 本地已不存在,本提交记为删除
This commit is contained in:
commit
856e632807
880 files changed
+225426
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
[]
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# dsh-plugin-douyin-account-detail bundle layer: registers the plugin row.
|
||||
# The detail page registers into the MCN workspace entry registry
|
||||
# (window.__mcnEntries, hidden: true) and is opened programmatically
|
||||
# from the account list row (__mcnNav.open('douyin-account-detail', ...)).
|
||||
- insert:
|
||||
- id: douyin-account-detail
|
||||
name: dsh-plugin-douyin-account-detail
|
||||
+878
@@ -0,0 +1,878 @@
|
||||
window.__ModuleLoader__.load({
|
||||
id: "dsh-plugin-douyin-account-detail",
|
||||
factory: (require) => {
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||
let react = require("react");
|
||||
let react_dom = require("react-dom");
|
||||
//#region plugin css(暗色适配:嵌套变量 + 无浅色 fallback,见踩坑手册 3.4)
|
||||
const css = ".dvd_page{position:relative;background:var(--dsw-alias-bg-base);width:100%;min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex:1;display:grid;grid-template-rows:minmax(0,1fr);overflow:hidden}.dvd_body{display:flex;flex-direction:column;height:100%;min-height:0;padding:16px;box-sizing:border-box;gap:12px}.dvd_toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.dvd_btn{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 14px;font:var(--dsw-font-s-14);cursor:pointer;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);transition:background var(--ds-transition-duration-fast) var(--ds-ease-in-out)}.dvd_btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dvd_btn:disabled{opacity:.55;cursor:not-allowed}.dvd_btnSm{padding:3px 10px;font-size:12px;border-radius:6px;white-space:nowrap;min-width:70px}.dvd_btnPrimary{background:var(--dsw-alias-brand-primary,#4d6bfe);color:#fff;border-color:transparent}.dvd_btnPrimary:hover{background:var(--dsw-alias-brand-primary-hover,#3a55d6)}.dvd_mask{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:flex-start;justify-content:center;padding-top:26vh;z-index:100}.dvd_modal{background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border-radius:12px;padding:20px;min-width:320px;max-width:480px;width:min(480px,92vw);box-shadow:0 8px 30px rgba(0,0,0,.2)}.dvd_modalTitle{font-size:16px;font-weight:600;margin-bottom:12px;color:var(--dsw-alias-label-primary)}.dvd_modalText{font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary);margin-bottom:16px;line-height:1.7;word-break:break-all;display:flex;flex-direction:column;gap:6px}.dvd_modalBtns{display:flex;justify-content:flex-end;gap:8px}.dvd_title{font:var(--dsw-font-l-16);font-weight:600;color:var(--dsw-alias-label-primary)}.dvd_link{color:var(--dsw-alias-accent-fg);cursor:pointer;text-decoration:none;border-bottom:1px dashed var(--dsw-alias-accent-fg,transparent)}.dvd_link:hover{text-decoration:underline}.dvd_badge{display:inline-block;padding:2px 10px;border-radius:999px;font:var(--dsw-font-s-12);line-height:20px}.dvd_badgeSigned{background:var(--dsw-alias-accent-bg-subtle,var(--dsw-alias-accent-bg-soft));color:var(--dsw-alias-accent-fg)}.dvd_badgeExternal{background:var(--dsw-alias-ok-bg-subtle,var(--dsw-alias-ok-bg-soft));color:var(--dsw-alias-ok-fg)}.dvd_tabsCard{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));overflow:hidden}.dvd_infoGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px 18px;flex:0 0 auto;margin-bottom:12px}.dvd_infoItem{display:flex;flex-direction:column;gap:4px;min-width:0}.dvd_infoItemWide{grid-column:1/-1}.dvd_infoLabel{font:var(--dsw-font-s-12);color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_infoValue{font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary);word-break:break-word;white-space:pre-wrap;line-height:1.6;max-height:140px;overflow-y:auto}.dvd_tableWrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px}.dvd_table{width:100%;border-collapse:collapse;font:var(--dsw-font-s-14);min-width:1180px}.dvd_table th{position:sticky;top:0;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));text-align:left;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));font-weight:600;white-space:nowrap;user-select:none}.dvd_table td{padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));vertical-align:middle;white-space:nowrap;max-width:260px;overflow:hidden;text-overflow:ellipsis;line-height:1.5}.dvd_table td.dvd_cellTitle{max-width:300px}.dvd_pager{display:flex;align-items:center;gap:12px;flex:0 0 auto;margin-top:10px}.dvd_pagerInfo{font:var(--dsw-font-s-13);color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_error{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-danger-fg,var(--dsw-alias-error-fg))}.dvd_empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}.dvd_tabs{display:flex;align-items:stretch;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));padding:0 8px;gap:2px}.dvd_tab{background:none;border:none;padding:10px 16px;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--dLine truncated
|
||||
const key = "dsh-plugin-douyin-account-detail/Detail.module.css";
|
||||
if (!document.querySelector("style[data-plugin-css=" + JSON.stringify(key) + "]")) {
|
||||
const el = document.createElement("style");
|
||||
el.dataset.plugin = "dsh-plugin-douyin-account-detail";
|
||||
el.dataset.pluginCss = key;
|
||||
el.textContent = css;
|
||||
if (!document.querySelector('style[data-plugin-css="dvd-align"]')) {
|
||||
const el2 = document.createElement("style");
|
||||
el2.dataset.pluginCss = "dvd-align";
|
||||
el2.textContent = ".dvd_table thead th:last-child{text-align:center!important}.dvd_table tbody td:last-child{text-align:center!important}";
|
||||
document.head.appendChild(el2);
|
||||
}
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
const cssMap = {
|
||||
"page": "dvd_page",
|
||||
"body": "dvd_body",
|
||||
"toolbar": "dvd_toolbar",
|
||||
"btn": "dvd_btn",
|
||||
"btnSm": "dvd_btnSm",
|
||||
"btnPrimary": "dvd_btnPrimary",
|
||||
"mask": "dvd_mask",
|
||||
"modal": "dvd_modal",
|
||||
"modalTitle": "dvd_modalTitle",
|
||||
"modalText": "dvd_modalText",
|
||||
"modalBtns": "dvd_modalBtns",
|
||||
"title": "dvd_title",
|
||||
"link": "dvd_link",
|
||||
"badge": "dvd_badge",
|
||||
"badgeSigned": "dvd_badgeSigned",
|
||||
"badgeExternal": "dvd_badgeExternal",
|
||||
"infoCard": "dvd_tabsCard",
|
||||
"infoGrid": "dvd_infoGrid",
|
||||
"infoItem": "dvd_infoItem",
|
||||
"infoItemWide": "dvd_infoItemWide",
|
||||
"infoLabel": "dvd_infoLabel",
|
||||
"infoValue": "dvd_infoValue",
|
||||
"tabs": "dvd_tabs",
|
||||
"tab": "dvd_tab",
|
||||
"tabActive": "dvd_tabActive",
|
||||
"tabPanel": "dvd_tabPanel",
|
||||
"personaPanel": "dvd_personaPanel",
|
||||
"tabState": "dvd_tabState",
|
||||
"md": "dvd_md",
|
||||
"tableWrap": "dvd_tableWrap",
|
||||
"table": "dvd_table",
|
||||
"cellTitle": "dvd_cellTitle",
|
||||
"pager": "dvd_pager",
|
||||
"pagerInfo": "dvd_pagerInfo",
|
||||
"loading": "dvd_loading",
|
||||
"error": "dvd_error",
|
||||
"empty": "dvd_empty"
|
||||
};
|
||||
//#endregion
|
||||
//#region 极简 markdown 渲染(账号设定 tab:无第三方依赖,渲染为 React 元素避免 XSS)
|
||||
// 行内语法:**粗体** / *斜体* / `行内代码` / [链接](url)(仅允许 http/https/mailto)
|
||||
const INLINE_RE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
|
||||
// 文本段:含 \n 时拆为文本 + <br/>(段落内换行在 HTML 中默认折叠为空格,需显式换行)
|
||||
function textWithBreaks(seg, keyBase) {
|
||||
if (!seg.includes("\n")) return [seg];
|
||||
const parts = seg.split("\n");
|
||||
const out = [];
|
||||
parts.forEach((p, i) => {
|
||||
if (p) out.push(p);
|
||||
if (i < parts.length - 1) out.push(react.createElement("br", { key: keyBase + "-br" + i }));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function parseInline(text, keyBase) {
|
||||
if (!text) return [text];
|
||||
const nodes = [];
|
||||
let last = 0, k = 0;
|
||||
for (const m of text.matchAll(INLINE_RE)) {
|
||||
if (m.index > last) nodes.push(...textWithBreaks(text.slice(last, m.index), keyBase + "-t" + k));
|
||||
const tok = m[0];
|
||||
if (tok.startsWith("**") && tok.endsWith("**") && tok.length > 4) {
|
||||
nodes.push(react.createElement("strong", { key: keyBase + "-b" + k }, tok.slice(2, -2)));
|
||||
} else if (tok.startsWith("*") && tok.endsWith("*") && tok.length > 2) {
|
||||
nodes.push(react.createElement("em", { key: keyBase + "-i" + k }, tok.slice(1, -1)));
|
||||
} else if (tok.startsWith("`") && tok.endsWith("`") && tok.length > 2) {
|
||||
nodes.push(react.createElement("code", { key: keyBase + "-c" + k }, tok.slice(1, -1)));
|
||||
} else if (tok.startsWith("[")) {
|
||||
const mm = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
const href = mm && /^(https?:|mailto:)/i.test(mm[2]) ? mm[2] : null;
|
||||
nodes.push(href
|
||||
? react.createElement("a", { key: keyBase + "-a" + k, href: href, target: "_blank", rel: "noopener noreferrer" }, mm[1])
|
||||
: (mm ? mm[1] : tok));
|
||||
} else {
|
||||
nodes.push(tok);
|
||||
}
|
||||
last = m.index + tok.length;
|
||||
k++;
|
||||
}
|
||||
if (last < text.length) nodes.push(...textWithBreaks(text.slice(last), keyBase + "-t" + k));
|
||||
return nodes;
|
||||
}
|
||||
// 表格块:rows 为已去掉分隔行的字符串数组
|
||||
function mdTable(rows, keyBase) {
|
||||
const split = (r) => r.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
||||
const head = split(rows[0]);
|
||||
const body = rows.slice(1).map(split);
|
||||
const firstColStyle = { width: "88px", maxWidth: "88px", minWidth: 0, wordBreak: "break-word" };
|
||||
return react.createElement("table", { key: keyBase, style: { width: "100%" } },
|
||||
react.createElement("thead", null,
|
||||
react.createElement("tr", null, head.map((h, i) => react.createElement("th", { key: "h" + i, style: i === 0 ? firstColStyle : undefined }, parseInline(h, keyBase + "-h" + i))))),
|
||||
react.createElement("tbody", null,
|
||||
body.map((row, ri) => react.createElement("tr", { key: "r" + ri },
|
||||
row.map((c, ci) => react.createElement("td", { key: "c" + ci, style: ci === 0 ? firstColStyle : undefined }, parseInline(c, keyBase + "-c" + ri + "-" + ci)))))));
|
||||
}
|
||||
// 块级解析:标题/代码块/引用/表格/列表/分隔线/段落
|
||||
function mdToReact(md) {
|
||||
if (typeof md !== "string" || !md.trim()) return null;
|
||||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||||
const out = [];
|
||||
let i = 0, k = 0;
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
// 围栏代码块
|
||||
const fence = t.match(/^```(\S*)\s*$/);
|
||||
if (fence) {
|
||||
const buf = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) { buf.push(lines[i]); i++; }
|
||||
i++;
|
||||
out.push(react.createElement("pre", { key: "md" + k++ },
|
||||
react.createElement("code", null, buf.join("\n"))));
|
||||
continue;
|
||||
}
|
||||
// 标题
|
||||
const h = t.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (h) {
|
||||
out.push(react.createElement("h" + h[1].length, { key: "md" + k++ }, parseInline(h[2], "h" + k)));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 分隔线
|
||||
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) {
|
||||
out.push(react.createElement("hr", { key: "md" + k++ }));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 引用
|
||||
if (t.startsWith(">")) {
|
||||
const buf = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith(">")) { buf.push(lines[i].trim().replace(/^>\s?/, "")); i++; }
|
||||
out.push(react.createElement("blockquote", { key: "md" + k++ }, mdToReact(buf.join("\n"))));
|
||||
continue;
|
||||
}
|
||||
// 表格:当前行含 | 且下一行为 |---| 分隔行
|
||||
if (t.includes("|") && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes("-")) {
|
||||
const sep = lines[i + 1].replace(/\s/g, "");
|
||||
if (/^\|?(:?-+:?\|)+:?-+:?\|?$/.test(sep)) {
|
||||
const buf = [t];
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].trim().includes("|")) { buf.push(lines[i].trim()); i++; }
|
||||
out.push(mdTable(buf, "md" + k++));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 无序列表
|
||||
const ul = t.match(/^[-*+]\s+(.*)$/);
|
||||
if (ul) {
|
||||
const items = [];
|
||||
while (i < lines.length) {
|
||||
const mt = lines[i].trim().match(/^[-*+]\s+(.*)$/);
|
||||
if (mt) { items.push(mt[1]); i++; continue; }
|
||||
break;
|
||||
}
|
||||
out.push(react.createElement("ul", { key: "md" + k++ },
|
||||
items.map((it, ii) => react.createElement("li", { key: ii }, parseInline(it, "li" + k + "-" + ii)))));
|
||||
continue;
|
||||
}
|
||||
// 有序列表
|
||||
const ol = t.match(/^\d+[.)]\s+(.*)$/);
|
||||
if (ol) {
|
||||
const items = [];
|
||||
while (i < lines.length) {
|
||||
const mt = lines[i].trim().match(/^\d+[.)]\s+(.*)$/);
|
||||
if (mt) { items.push(mt[1]); i++; continue; }
|
||||
break;
|
||||
}
|
||||
out.push(react.createElement("ol", { key: "md" + k++ },
|
||||
items.map((it, ii) => react.createElement("li", { key: ii }, parseInline(it, "li" + k + "-" + ii)))));
|
||||
continue;
|
||||
}
|
||||
// 空行跳过
|
||||
if (t === "") { i++; continue; }
|
||||
// 段落:合并连续普通行(不跨空行)
|
||||
const para = [t];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const nt = lines[i].trim();
|
||||
if (nt === "" || /^(#{1,4}\s|```|>|[-*+]\s|\d+[.)]\s|.*\|)/.test(nt) || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(nt)) break;
|
||||
para.push(nt);
|
||||
i++;
|
||||
}
|
||||
out.push(react.createElement("p", { key: "md" + k++ }, parseInline(para.join("\n"), "p" + k)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
//#endregion
|
||||
//#region 账号详情页
|
||||
// 统一线性 SVG 图标(Lucide 风格:24 viewBox、stroke currentColor、圆角端点)
|
||||
const SvgBase = ({ d, size = 16 }) => react.createElement("svg", {
|
||||
viewBox: "0 0 24 24",
|
||||
width: size,
|
||||
height: size,
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: "2",
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
"aria-hidden": "true",
|
||||
style: { verticalAlign: "-2px" },
|
||||
}, d);
|
||||
const IconDownload = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("path", { key: "p1", d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), react.createElement("polyline", { key: "p2", points: "7 10 12 15 17 10" }), react.createElement("line", { key: "p3", x1: "12", y1: "15", x2: "12", y2: "3" })] });
|
||||
const IconChart = ({ size }) => react.createElement(SvgBase, { size, d: [react.createElement("path", { key: "p1", d: "M3 3v18h18" }), react.createElement("path", { key: "p2", d: "M18 17V9" }), react.createElement("path", { key: "p3", d: "M13 17V5" }), react.createElement("path", { key: "p4", d: "M8 17v-3" })] });
|
||||
const fmt = (v) => (v == null || v === "") ? "-" : String(v);
|
||||
// 时长格式化:秒 → 「X分Y秒」(不足 1 分钟显示「X秒」)
|
||||
const fmtDuration = (v) => {
|
||||
const s = Number(v);
|
||||
if (!Number.isFinite(s) || s <= 0) return "-";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.round(s % 60);
|
||||
return m > 0 ? `${m}分${sec}秒` : `${sec}秒`;
|
||||
};
|
||||
// 视频表格列
|
||||
const COLUMNS = [
|
||||
["序号", (r, i) => String(i)],
|
||||
["视频ID", (r) => r.aweme_id],
|
||||
["视频标题", (r) => r.video_title],
|
||||
["点赞", (r) => r.like_display || fmt(r.like_count)],
|
||||
["评论", (r) => fmt(r.comment_count)],
|
||||
["分享", (r) => fmt(r.share_count)],
|
||||
["收藏", (r) => fmt(r.collect_count)],
|
||||
["播放", (r) => (r.play_count == null || Number(r.play_count) === 0) ? "-" : fmt(r.play_count)],
|
||||
["时长", (r) => fmtDuration(r.duration)],
|
||||
["发布时间", (r) => fmt(r.publish_time)],
|
||||
["标签", (r) => fmt(r.tags)]
|
||||
];
|
||||
//#region V1.0 同步:视频互动趋势(lieflat-charts 风格内联 SVG 双序列折线;不引第三方图表库)
|
||||
// 数据 = 该账号全部视频(分页循环拉取,独立于列表分页);窗口 = 近半年(按发布时间)
|
||||
const TREND_DAYS = 180;
|
||||
const TREND_COLOR_LIKE = "#e5484d";
|
||||
const TREND_COLOR_COMMENT = "#2f6fed";
|
||||
// 数值混合解析:数字原样;"7.8万"/"500w"/"1.2亿" → 数值
|
||||
function numOfMix(v) {
|
||||
const s = String(v == null ? "" : v).trim();
|
||||
if (!s) return 0;
|
||||
const m = s.match(/^([\d.]+)\s*(亿|w|万)?$/);
|
||||
if (!m) return Number(s) || 0;
|
||||
let n = parseFloat(m[1]) || 0;
|
||||
if (m[2] === "亿") n *= 100000000;
|
||||
else if (m[2]) n *= 10000;
|
||||
return n;
|
||||
}
|
||||
function fmtWan(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return "0";
|
||||
return n >= 10000 ? (n / 10000).toFixed(n >= 1000000 ? 0 : 1) + "万" : String(Math.round(n));
|
||||
}
|
||||
// videos → 升序趋势点(近半年内有发布时间的视频);不足 2 点返回 [](前端显示空态提示)
|
||||
function buildTrendPoints(videos) {
|
||||
const cutoff = Date.now() - TREND_DAYS * 86400000;
|
||||
const pts = [];
|
||||
for (const v of videos || []) {
|
||||
const ds = String(v.publish_time || "").slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ds)) continue;
|
||||
const t = new Date(ds + "T00:00:00");
|
||||
if (Number.isNaN(t.getTime()) || t.getTime() < cutoff) continue;
|
||||
pts.push({
|
||||
d: ds,
|
||||
like: numOfMix(v.like_display || v.like_count),
|
||||
comment: numOfMix(v.comment_count),
|
||||
title: String(v.video_title || "").slice(0, 40) || ds,
|
||||
});
|
||||
}
|
||||
pts.sort((a, b) => (a.d < b.d ? -1 : a.d > b.d ? 1 : 0));
|
||||
return pts;
|
||||
}
|
||||
// 内联 SVG 双序列折线(左轴=点赞 右轴=评论,各自归一化到自身量程)
|
||||
function trendSvg(pts) {
|
||||
const W = 640, H = 220, PL = 62, PR = 62, PT = 14, PB = 26;
|
||||
const n = pts.length;
|
||||
if (n < 2) return null;
|
||||
const xs = (i) => PL + (W - PL - PR) * i / (n - 1);
|
||||
const lmax = Math.max.apply(null, pts.map((p) => p.like));
|
||||
const lmin = Math.min.apply(null, pts.map((p) => p.like));
|
||||
const cmax = Math.max.apply(null, pts.map((p) => p.comment));
|
||||
const cmin = Math.min.apply(null, pts.map((p) => p.comment));
|
||||
const span = (lo, hi) => Math.max(hi - lo, 1);
|
||||
const yl = (v) => PT + (H - PT - PB) * (1 - (v - lmin) / span(lmin, Math.max(lmax, 1)));
|
||||
const yc = (v) => PT + (H - PT - PB) * (1 - (v - cmin) / span(cmin, Math.max(cmax, 1)));
|
||||
const grid = [0.25, 0.5, 0.75].map((g) => {
|
||||
const y = PT + (H - PT - PB) * g;
|
||||
return react.createElement("line", { key: "g" + g, x1: PL, x2: W - PR, y1: y, y2: y,
|
||||
style: { stroke: "var(--dsw-alias-border-l2,#e8e8ea)", strokeWidth: 1 } });
|
||||
});
|
||||
const likePts = pts.map((p, i) => `${xs(i).toFixed(1)},${yl(p.like).toFixed(1)}`).join(" ");
|
||||
const cmtPts = pts.map((p, i) => `${xs(i).toFixed(1)},${yc(p.comment).toFixed(1)}`).join(" ");
|
||||
const dots = n <= 40
|
||||
? pts.map((p, i) => [
|
||||
react.createElement("circle", { key: "dl" + i, cx: xs(i), cy: yl(p.like), r: 2.2, fill: TREND_COLOR_LIKE }),
|
||||
react.createElement("circle", { key: "dc" + i, cx: xs(i), cy: yc(p.comment), r: 2.2, fill: TREND_COLOR_COMMENT }),
|
||||
])
|
||||
: [];
|
||||
const last = pts[n - 1];
|
||||
const labelFill = { fill: "var(--dsw-alias-label-secondary,#8a8f98)", fontSize: 10 };
|
||||
return react.createElement("svg", { viewBox: "0 0 " + W + " " + H, style: { width: "100%", height: "auto", display: "block" }, "aria-label": "互动趋势图" },
|
||||
grid,
|
||||
react.createElement("polyline", { key: "pl", points: likePts, fill: "none",
|
||||
style: { stroke: TREND_COLOR_LIKE, strokeWidth: 1.8 } }),
|
||||
react.createElement("polyline", { key: "pc", points: cmtPts, fill: "none",
|
||||
style: { stroke: TREND_COLOR_COMMENT, strokeWidth: 1.8 } }),
|
||||
dots,
|
||||
// 左轴:点赞 量程(顶部=最大)
|
||||
react.createElement("text", { x: PL - 6, y: PT + 8, textAnchor: "end", style: Object.assign({ fill: TREND_COLOR_LIKE, fontSize: 10 }, {}) }, fmtWan(lmax)),
|
||||
react.createElement("text", { x: PL - 6, y: H - PB, textAnchor: "end", style: labelFill }, "0"),
|
||||
// 右轴:评论 量程(顶部=最大)
|
||||
react.createElement("text", { x: W - PR + 6, y: PT + 8, textAnchor: "start", style: { fill: TREND_COLOR_COMMENT, fontSize: 10 } }, fmtWan(cmax)),
|
||||
react.createElement("text", { x: W - PR + 6, y: H - PB, textAnchor: "start", style: labelFill }, "0"),
|
||||
// 日期刻度:首/中/尾
|
||||
react.createElement("text", { x: PL, y: H - 8, textAnchor: "start", style: labelFill }, pts[0].d.slice(5)),
|
||||
react.createElement("text", { x: (PL + W - PR) / 2, y: H - 8, textAnchor: "middle", style: labelFill },
|
||||
pts[Math.floor((n - 1) / 2)].d.slice(5)),
|
||||
react.createElement("text", { x: W - PR, y: H - 8, textAnchor: "end", style: labelFill }, last.d.slice(5)));
|
||||
}
|
||||
//#endregion
|
||||
function AccountDetailPage({ params }) {
|
||||
const accountId = params && params.accountId != null ? Number(params.accountId) : null;
|
||||
const [account, setAccount] = react.useState(null);
|
||||
const [items, setItems] = react.useState([]);
|
||||
const [total, setTotal] = react.useState(0);
|
||||
const [page, setPage] = react.useState(1);
|
||||
const [pageSize] = react.useState(20);
|
||||
const [loading, setLoading] = react.useState(accountId != null);
|
||||
const [error, setError] = react.useState(null);
|
||||
// 账号信息 / 账号设定 tab 状态(设定内容懒加载,仅首次切换时请求)
|
||||
const [activeTab, setActiveTab] = react.useState("info");
|
||||
const [personaLoading, setPersonaLoading] = react.useState(false);
|
||||
const [personaError, setPersonaError] = react.useState(null);
|
||||
const [personaData, setPersonaData] = react.useState(null);
|
||||
const [personaLoaded, setPersonaLoaded] = react.useState(false);
|
||||
// V1.0 同步:账号数据分析 tab(读 account_analysis 最新 md;空态引导)
|
||||
const [analysisData, setAnalysisData] = react.useState(null);
|
||||
const [analysisLoading, setAnalysisLoading] = react.useState(false);
|
||||
const [analysisError, setAnalysisError] = react.useState(null);
|
||||
const [analysisLoaded, setAnalysisLoaded] = react.useState(false);
|
||||
const [analysisBusy, setAnalysisBusy] = react.useState(false);
|
||||
const loadAnalysisRef = react.useRef(null);
|
||||
// V1.0 同步:视频互动趋势(近半年点赞/评论折线,数据=该账号全部视频,与分页无关)
|
||||
const [trendPoints, setTrendPoints] = react.useState(null);
|
||||
// 对标创作状态(toast 提示 + 进行中的视频行 + 确认弹窗行)
|
||||
const [toast, setToast] = react.useState(null);
|
||||
const [creating, setCreating] = react.useState(null);
|
||||
const [benchmarkRow, setBenchmarkRow] = react.useState(null);
|
||||
// 视频解析确认弹窗:待解析视频行
|
||||
const [analyzeRow, setAnalyzeRow] = react.useState(null);
|
||||
// 账号设定提炼:确认弹窗行 + 提炼中状态(每账号独立会话,多账号可并发)
|
||||
const [personaRow, setPersonaRow] = react.useState(null);
|
||||
const [personaBusy, setPersonaBusy] = react.useState(false);
|
||||
const accountIdRef = react.useRef(accountId);
|
||||
accountIdRef.current = accountId;
|
||||
// 当前账号刷新任务状态跟踪(imports 轮询用)
|
||||
const refreshPrevRef = react.useRef(false);
|
||||
// 视频解析任务状态(并发):videoId -> true 表示该视频解析中(每视频独立会话,互不阻塞)
|
||||
const [analyzing, setAnalyzing] = react.useState({});
|
||||
const analyzingRef = react.useRef({});
|
||||
analyzingRef.current = analyzing;
|
||||
const pageRef = react.useRef(1);
|
||||
pageRef.current = page;
|
||||
const loadRef = react.useRef(null);
|
||||
|
||||
const load = (p) => {
|
||||
if (accountId == null) return;
|
||||
setLoading(true);
|
||||
fetch("/mcn/api/account/videos?accountId=" + accountId + "&page=" + p + "&pageSize=" + pageSize)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||||
setAccount(d.account || null);
|
||||
setItems(d.items || []);
|
||||
setTotal(d.total || 0);
|
||||
setPage(d.page || 1);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
loadRef.current = load;
|
||||
// V1.0 同步:拉取该账号全量视频(分页 pageSize=100,上限 6 页)→ 计算趋势点(近半年,独立于列表分页)
|
||||
const loadTrend = () => {
|
||||
if (accountId == null) return;
|
||||
setTrendPoints(null);
|
||||
const acc = [];
|
||||
const fetchPage = (page) =>
|
||||
fetch("/mcn/api/account/videos?accountId=" + accountId + "&page=" + page + "&pageSize=100")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||||
acc.push.apply(acc, d.items || []);
|
||||
if (acc.length < (d.total || 0) && page < 6) return fetchPage(page + 1);
|
||||
setTrendPoints(buildTrendPoints(acc));
|
||||
})
|
||||
.catch(() => setTrendPoints([]));
|
||||
fetchPage(1);
|
||||
};
|
||||
// 轮询视频解析任务(5s):某视频从 running → 非 running 即解析完成,自动刷新列表(并发多任务各自独立,完成后各自刷新)
|
||||
react.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
fetch("/mcn/api/task/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok || !Array.isArray(d.analyze)) return;
|
||||
const running = {};
|
||||
d.analyze.forEach((a) => { if (a.status === "running") running[a.videoId] = true; });
|
||||
const prev = analyzingRef.current;
|
||||
const next = {};
|
||||
let doneAny = false;
|
||||
Object.keys(prev).forEach((id) => {
|
||||
if (running[id]) next[id] = true;
|
||||
else doneAny = true; // 之前解析中 → 现在空闲:该视频解析完成
|
||||
});
|
||||
Object.keys(running).forEach((id) => { if (!prev[id]) next[id] = true; });
|
||||
setAnalyzing(next);
|
||||
if (doneAny) {
|
||||
const p = pageRef.current;
|
||||
setTimeout(() => loadRef.current(p), 300);
|
||||
}
|
||||
// persona:当前账号提炼状态(running→idle 即完成,刷新账号设定数据)
|
||||
const plist = Array.isArray(d.persona) ? d.persona : [];
|
||||
const curId = accountIdRef.current;
|
||||
const pCur = plist.find((p) => p.accountId === curId);
|
||||
const pRunning = !!(pCur && pCur.status === "running");
|
||||
setPersonaBusy((prev) => {
|
||||
if (pRunning) return true;
|
||||
if (prev && !pRunning) {
|
||||
setTimeout(() => {
|
||||
fetch("/mcn/api/account/persona?accountId=" + accountIdRef.current)
|
||||
.then((r) => r.json())
|
||||
.then((dd) => {
|
||||
if (dd.ok) { setPersonaData({ content: dd.content, analysisTime: dd.analysisTime }); setPersonaLoaded(true); }
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 300);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// accountAnalysis:账号数据分析任务(running→idle 即完成,自动刷新数据分析内容)
|
||||
const alist2 = Array.isArray(d.accountAnalysis) ? d.accountAnalysis : [];
|
||||
const aCur = alist2.find((t) => t.accountId === accountIdRef.current);
|
||||
const aRunning = !!(aCur && aCur.status === "running");
|
||||
setAnalysisBusy((prev) => {
|
||||
if (aRunning) return true;
|
||||
if (prev && !aRunning) {
|
||||
setTimeout(() => { setAnalysisLoaded(false); loadAnalysisRef.current(); }, 300);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// imports:当前账号刷新任务(refresh:{accountId})running→idle 即完成,自动刷新账号信息与视频列表
|
||||
const ilist = Array.isArray(d.imports) ? d.imports : [];
|
||||
const curId2 = accountIdRef.current;
|
||||
const refTask = ilist.find((t) => t.id === "refresh:" + curId2);
|
||||
const refRunning = !!(refTask && refTask.status === "running");
|
||||
if (refreshPrevRef.current && !refRunning) {
|
||||
const p = pageRef.current;
|
||||
setTimeout(() => loadRef.current(p), 300);
|
||||
}
|
||||
refreshPrevRef.current = refRunning;
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
react.useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = setTimeout(() => setToast(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast]);
|
||||
react.useEffect(() => {
|
||||
// 切换账号时重置 tab 状态,避免残留上一账号的内容
|
||||
setActiveTab("info");
|
||||
setPersonaLoaded(false);
|
||||
setPersonaData(null);
|
||||
setPersonaError(null);
|
||||
setToast(null);
|
||||
setCreating(null);
|
||||
setBenchmarkRow(null);
|
||||
setAnalyzeRow(null);
|
||||
setPersonaRow(null);
|
||||
setPersonaBusy(false);
|
||||
// V1.0 同步:数据分析 tab 状态随账号重置;趋势数据重新拉取
|
||||
setAnalysisLoaded(false);
|
||||
setAnalysisData(null);
|
||||
setAnalysisError(null);
|
||||
setTrendPoints(null);
|
||||
loadTrend();
|
||||
loadPersona();
|
||||
load(1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountId]);
|
||||
|
||||
// 无参数(从工作台直接打开):提示从账号列表进入
|
||||
if (accountId == null) {
|
||||
return react.createElement("main", { className: cssMap.page, "aria-label": "账号详情" },
|
||||
react.createElement("div", { className: cssMap.body },
|
||||
react.createElement("div", { className: cssMap.toolbar },
|
||||
react.createElement("button", { type: "button", className: cssMap.btn, title: "返回账号列表", onClick: () => window.__mcnNav?.back() }, "←")),
|
||||
react.createElement("div", { className: cssMap.empty }, "请从「热门账号」列表点击账号的抖音号进入详情")));
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
// 加载账号设定信息(读取 account_persona 最新一条 markdown 内容)
|
||||
const loadPersona = () => {
|
||||
setPersonaLoading(true);
|
||||
setPersonaError(null);
|
||||
fetch("/mcn/api/account/persona?accountId=" + accountId)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||||
setPersonaData({ content: d.content, analysisTime: d.analysisTime });
|
||||
setPersonaLoaded(true);
|
||||
})
|
||||
.catch((e) => setPersonaError(e.message))
|
||||
.finally(() => setPersonaLoading(false));
|
||||
};
|
||||
// 切换 tab;账号设定内容懒加载(仅首次);数据分析懒加载(仅首次)
|
||||
const switchTab = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === "info" && !personaLoaded) loadPersona();
|
||||
if (tab === "analysis" && !analysisLoaded) loadAnalysis();
|
||||
};
|
||||
// 加载账号数据分析(读取 account_analysis 最新一条 markdown 内容,V1.0 同步)
|
||||
const loadAnalysis = () => {
|
||||
setAnalysisLoading(true);
|
||||
setAnalysisError(null);
|
||||
fetch("/mcn/api/account/analysis?accountId=" + accountId)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok) throw new Error(d.error || "加载失败");
|
||||
setAnalysisData({ content: d.content, analysisTime: d.analysisTime });
|
||||
setAnalysisLoaded(true);
|
||||
})
|
||||
.catch((e) => setAnalysisError(e.message))
|
||||
.finally(() => setAnalysisLoading(false));
|
||||
};
|
||||
loadAnalysisRef.current = loadAnalysis;
|
||||
const tabBtn = (label, value) => react.createElement("button", {
|
||||
type: "button",
|
||||
role: "tab",
|
||||
"aria-selected": activeTab === value,
|
||||
className: cssMap.tab + (activeTab === value ? " " + cssMap.tabActive : ""),
|
||||
onClick: () => switchTab(value),
|
||||
}, label);
|
||||
// 改写脚本:点击按钮 → 确认弹窗 → 账号设定 + 视频选题(只要选题)→ 创作全新故事脚本
|
||||
// 复用固定会话 dsh_rewrite(无则新建),单任务串行:运行中重复点击会被宿主 409 拒绝
|
||||
const REWRITE_SESSION = "dsh_rewrite";
|
||||
const openBenchmark = (row) => setBenchmarkRow(row);
|
||||
const confirmBenchmark = () => {
|
||||
const row = benchmarkRow;
|
||||
if (!row) return;
|
||||
setBenchmarkRow(null);
|
||||
setCreating(row.id);
|
||||
// 账号设定:优先用已加载的缓存,否则按需拉取
|
||||
const personaP = personaData && personaData.content
|
||||
? Promise.resolve(personaData.content)
|
||||
: fetch("/mcn/api/account/persona?accountId=" + accountId)
|
||||
.then((r) => r.json())
|
||||
.then((d) => (d.ok ? (d.content || "") : ""));
|
||||
personaP
|
||||
.then((persona) => {
|
||||
const lines = [
|
||||
`请为达人「${account.account_name}」改写一条短视频故事脚本。`,
|
||||
``,
|
||||
`【账号信息】`,
|
||||
`赛道:${account.track || "-"}`,
|
||||
`账号定位:${account.content || "-"}`,
|
||||
``,
|
||||
`【账号设定】`,
|
||||
persona || "(该账号暂无账号设定文档)",
|
||||
``,
|
||||
`【视频选题】`,
|
||||
row.topic
|
||||
? row.topic
|
||||
: `标题:${row.video_title || "-"}\n视频ID:${row.aweme_id || "-"}`,
|
||||
``,
|
||||
`请基于账号设定与上述视频选题,创作一个全新的故事脚本:`,
|
||||
`- 新故事、新情节,不要复刻原视频;`,
|
||||
`- 输出完整故事脚本(含标题、故事梗概、口播文案/台词);`,
|
||||
`- 不要输出分镜脚本,无需对标分析。`,
|
||||
].join("\n");
|
||||
return fetch("/mcn/api/creative", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt: lines, sessionId: REWRITE_SESSION }),
|
||||
}).then((r) => r.json());
|
||||
})
|
||||
.then((d) => {
|
||||
if (!d.ok) throw new Error(d.error || "触发失败");
|
||||
setToast(d.message || "已开始创作,请切换到对应会话查看");
|
||||
})
|
||||
.catch((e) => setToast("创作触发失败: " + e.message))
|
||||
.finally(() => setCreating(null));
|
||||
};
|
||||
// 解析视频:确认弹窗 → 每视频独立 AI 会话(mcn-dou-analysis 功能五)MCP 解析并保存/写库;多视频可并发提交
|
||||
const submitAnalyze = () => {
|
||||
const row = analyzeRow;
|
||||
if (!row) return;
|
||||
setAnalyzeRow(null);
|
||||
// 乐观标记:按钮立即变「解析中…」,轮询会从 task/status 同步真实状态
|
||||
setAnalyzing((prev) => { const n = Object.assign({}, prev); n[row.id] = true; return n; });
|
||||
fetch("/mcn/api/video/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ videoId: row.id }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok) {
|
||||
setAnalyzing((prev) => { const n = Object.assign({}, prev); delete n[row.id]; return n; });
|
||||
throw new Error(d.error || "提交失败");
|
||||
}
|
||||
setToast(d.message || "已提交解析,AI 正在解析(约 3-5 分钟)");
|
||||
})
|
||||
.catch((e) => setToast("解析提交失败: " + e.message));
|
||||
};
|
||||
// 提炼账号设定:确认弹窗 → 每账号独立 AI 会话(mcn-dou-analysis 功能三)筛选TOP4→MCP解析→生成人设卡并写库;多账号可并发
|
||||
const submitPersonaExtract = () => {
|
||||
const row = personaRow;
|
||||
if (!row) return;
|
||||
setPersonaRow(null);
|
||||
setPersonaBusy(true);
|
||||
fetch("/mcn/api/account/persona/extract", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountId: row.id }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok) { setPersonaBusy(false); throw new Error(d.error || "提交失败"); }
|
||||
setToast(d.message || "已提交提炼账号设定,AI 正在执行(约 10-20 分钟)");
|
||||
})
|
||||
.catch((e) => setToast("提炼提交失败: " + e.message));
|
||||
};
|
||||
// 发起账号数据分析(V1.0 同步:mcn-dou-analysis 功能四;每账号独立会话,运行中禁重复发起)
|
||||
const submitAccountAnalysis = () => {
|
||||
if (accountId == null) return;
|
||||
if (!window.confirm("对该账号发起账号数据分析(功能四:近 4 个月最好/最差视频逐条拆解 → 8 章汇总报告,约 10-20 分钟)?")) return;
|
||||
setAnalysisBusy(true);
|
||||
fetch("/mcn/api/account/analysis/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountId: accountId }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d.ok) { setAnalysisBusy(false); throw new Error(d.error || "提交失败"); }
|
||||
setToast(d.message || "已提交账号数据分析,AI 正在执行(约 10-20 分钟)");
|
||||
})
|
||||
.catch((e) => { setAnalysisBusy(false); setToast("数据分析提交失败: " + e.message); });
|
||||
};
|
||||
// 账号信息卡
|
||||
const infoItem = (label, value, wide) => {
|
||||
const cls = cssMap.infoItem + (wide ? " " + cssMap.infoItemWide : "");
|
||||
return react.createElement("div", { className: cls, key: label },
|
||||
react.createElement("div", { className: cssMap.infoLabel }, label),
|
||||
react.createElement("div", { className: cssMap.infoValue }, value));
|
||||
};
|
||||
let infoCard = null;
|
||||
let tableArea = null;
|
||||
if (loading) {
|
||||
tableArea = react.createElement("div", { className: cssMap.loading }, "加载中…");
|
||||
} else if (error) {
|
||||
tableArea = react.createElement("div", { className: cssMap.error }, error);
|
||||
} else {
|
||||
// 视频表格列(追加操作列:已解析(原始解析或拆解分析)→ 改写脚本;均无 → 下载图标占位;拆解详情经视频ID进入)
|
||||
const columns = COLUMNS.concat([[
|
||||
"操作",
|
||||
(row) => (row.has_source || row.has_analysis)
|
||||
? react.createElement("button", {
|
||||
type: "button",
|
||||
className: cssMap.btn + " " + cssMap.btnSm,
|
||||
disabled: creating === row.id,
|
||||
title: "基于账号设定与视频选题,改写一条全新的故事脚本",
|
||||
onClick: () => openBenchmark(row),
|
||||
}, creating === row.id ? "创作中…" : "改写脚本")
|
||||
: react.createElement("button", {
|
||||
type: "button",
|
||||
className: cssMap.btn + " " + cssMap.btnSm,
|
||||
disabled: !!analyzing[row.id],
|
||||
title: analyzing[row.id] ? "该视频正在解析中(约 3-5 分钟),可同时提交其他视频解析" : "解析本视频(MCP 解析,保存到本地并写入数据库)",
|
||||
onClick: () => setAnalyzeRow(row),
|
||||
}, analyzing[row.id] ? "解析中…" : react.createElement(IconDownload, {})),
|
||||
]]);
|
||||
// 视频表格(账号信息 tab 面板内:表格区滚动,分页固定在面板底部)
|
||||
const videoTable = react.createElement("div", { className: cssMap.tableWrap },
|
||||
items.length === 0
|
||||
? react.createElement("div", { className: cssMap.empty }, "暂无视频数据")
|
||||
: react.createElement("table", { className: cssMap.table },
|
||||
react.createElement("thead", null,
|
||||
react.createElement("tr", null,
|
||||
columns.map((c) => react.createElement("th", { key: c[0] }, c[0])))),
|
||||
react.createElement("tbody", null,
|
||||
items.map((row, idx) => react.createElement("tr", { key: row.id },
|
||||
columns.map((c) => {
|
||||
const val = c[1](row, (page - 1) * pageSize + idx + 1);
|
||||
const cellCls = c[0] === "视频标题" ? cssMap.cellTitle : "";
|
||||
// 视频标题:有 video_url 时渲染为新页面链接
|
||||
let content = val;
|
||||
let tip = c[0] === "视频标题" || c[0] === "标签" ? val : undefined;
|
||||
if (c[0] === "视频标题" && row.video_url) {
|
||||
content = react.createElement("a", {
|
||||
className: cssMap.link,
|
||||
href: row.video_url,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
title: "在新页面打开视频",
|
||||
}, val);
|
||||
tip = row.video_url;
|
||||
}
|
||||
// 视频ID:点击打开视频分析详情页
|
||||
if (c[0] === "视频ID") {
|
||||
content = react.createElement("span", {
|
||||
className: cssMap.link,
|
||||
title: "查看视频分析详情",
|
||||
onClick: () => window.__mcnNav?.open("douyin-video-detail", { videoId: row.id, awemeId: row.aweme_id, accountId: accountId }),
|
||||
}, val);
|
||||
tip = "查看视频分析详情";
|
||||
}
|
||||
return react.createElement("td", { key: c[0], className: cellCls, title: tip }, content);
|
||||
}))))));
|
||||
const pager = react.createElement("div", { className: cssMap.pager },
|
||||
react.createElement("span", { className: cssMap.pagerInfo }, `共 ${total} 条视频`),
|
||||
react.createElement("span", { className: cssMap.pagerInfo }, `第 ${page} / ${totalPages} 页`),
|
||||
react.createElement("button", { type: "button", className: cssMap.btn, disabled: page <= 1 || loading, onClick: () => load(page - 1) }, "上一页"),
|
||||
react.createElement("button", { type: "button", className: cssMap.btn, disabled: page >= totalPages || loading, onClick: () => load(page + 1) }, "下一页"));
|
||||
// V1.0 同步:数据分析 tab 面板(account_analysis 最新 md;空态引导)
|
||||
const analysisPanel = react.createElement("div", { className: cssMap.tabPanel + " " + cssMap.personaPanel },
|
||||
analysisLoading
|
||||
? react.createElement("div", { className: cssMap.tabState }, "加载中…")
|
||||
: analysisError
|
||||
? react.createElement("div", { className: cssMap.tabState }, "加载失败:" + analysisError)
|
||||
: analysisData && analysisData.content
|
||||
? react.createElement("div", { className: cssMap.md }, mdToReact(analysisData.content))
|
||||
: react.createElement("div", { className: cssMap.tabState },
|
||||
react.createElement("div", null, "该账号暂无账号数据分析"),
|
||||
react.createElement("div", { style: { marginTop: 8, fontSize: 12, lineHeight: 1.6, opacity: 0.85 } },
|
||||
"数据分析(账号内容结构拆解与定位诊断报告)生成后在此展示。可在对话中发起「分析这个账号的视频数据」,将按 mcn-dou-analysis 功能四执行并自动落库。")),
|
||||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, style: { marginTop: 12, alignSelf: "flex-start" }, disabled: analysisBusy || accountId == null, title: analysisBusy ? "该账号数据分析任务执行中(约 10-20 分钟),可先处理其他账号" : "调用抖音账号分析技能执行账号数据分析(功能四:样本拆解 → 8 章报告)", onClick: submitAccountAnalysis }, analysisBusy ? "分析中…" : "发起账号数据分析"),
|
||||
);
|
||||
// V1.0 同步:视频互动趋势区块(近半年点赞/评论,lieflat 风格内联 SVG;不足 2 点或加载中不显示)
|
||||
const trendBlock = trendPoints == null ? null
|
||||
: trendPoints.length >= 2
|
||||
? react.createElement("div", { style: { border: "1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))", borderRadius: 8, padding: "8px 12px 6px", marginBottom: 12, background: "var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))" } },
|
||||
react.createElement("div", { style: { display: "flex", alignItems: "center", gap: 14, marginBottom: 4, fontSize: 12 } },
|
||||
react.createElement("span", { style: { color: "var(--dsw-alias-label-primary)" } }, "互动趋势"),
|
||||
react.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, color: "#e5484d" } }, "● 点赞"),
|
||||
react.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, color: "#2f6fed" } }, "● 评论"),
|
||||
react.createElement("span", { style: { marginLeft: "auto", color: "var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))" } }, "近半年 · 按发布时间")),
|
||||
trendSvg(trendPoints))
|
||||
: (total > 0 ? react.createElement("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#8a8f98)", marginBottom: 12 } }, "近半年互动数据不足 2 条,暂不展示趋势") : null);
|
||||
|
||||
if (account) {
|
||||
infoCard = react.createElement("div", { className: cssMap.infoCard },
|
||||
react.createElement("div", { className: cssMap.tabs, role: "tablist" },
|
||||
tabBtn("账号信息", "info"),
|
||||
tabBtn("视频列表", "persona"),
|
||||
tabBtn("数据分析", "analysis")),
|
||||
activeTab === "info"
|
||||
? react.createElement("div", { className: cssMap.tabPanel },
|
||||
react.createElement("div", { className: cssMap.infoGrid },
|
||||
infoItem("抖音号", fmt(account.douyin_id)),
|
||||
infoItem("赛道", fmt(account.track)),
|
||||
infoItem("AI制作", account.is_ai === "是" ? "是" : "否"),
|
||||
infoItem("粉丝", fmt(account.followers)),
|
||||
infoItem("获赞", fmt(account.total_likes)),
|
||||
infoItem("作品数", fmt(account.works_count)),
|
||||
infoItem("地区", fmt(account.location)),
|
||||
infoItem("年龄", fmt(account.age)),
|
||||
infoItem("获取视频", fmt(total)),
|
||||
infoItem("简介", fmt(account.bio), true),
|
||||
infoItem("定位/内容", fmt(account.content), true)),
|
||||
react.createElement("div", { style: { margin: "18px 0 10px", paddingTop: 12, borderTop: "1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))", fontWeight: 600, fontSize: 15, color: "var(--dsw-alias-label-primary)" } }, "账号设定"),
|
||||
personaLoading
|
||||
? react.createElement("div", { className: cssMap.tabState }, "加载中…")
|
||||
: personaError
|
||||
? react.createElement("div", { className: cssMap.tabState }, "加载失败:" + personaError)
|
||||
: personaData && personaData.content
|
||||
? react.createElement("div", { className: cssMap.md }, mdToReact(personaData.content))
|
||||
: react.createElement("div", { className: cssMap.tabState },
|
||||
react.createElement("div", null, "该账号暂无账号设定信息"),
|
||||
react.createElement("button", {
|
||||
type: "button",
|
||||
className: cssMap.btn + " " + cssMap.btnPrimary,
|
||||
style: { marginTop: 12 },
|
||||
disabled: personaBusy,
|
||||
title: personaBusy ? "该账号正在提炼账号设定中(约 10-20 分钟),可先处理其他账号" : "调用抖音账号分析技能执行提炼账号设定(功能三:筛选TOP4→MCP解析→生成人设卡)",
|
||||
onClick: () => setPersonaRow(account),
|
||||
}, personaBusy ? "提炼中…" : "提炼账号设定")))
|
||||
: activeTab === "analysis"
|
||||
? analysisPanel
|
||||
: react.createElement("div", { className: cssMap.tabPanel },
|
||||
trendBlock,
|
||||
videoTable,
|
||||
pager));
|
||||
}
|
||||
}
|
||||
return react.createElement("main", { className: cssMap.page, "aria-label": "账号详情" },
|
||||
react.createElement("div", { className: cssMap.body },
|
||||
react.createElement("div", { className: cssMap.toolbar },
|
||||
react.createElement("button", { type: "button", className: cssMap.btn, title: "返回账号列表", onClick: () => window.__mcnNav?.back() }, "←"),
|
||||
account ? react.createElement("span", { className: cssMap.title }, account.account_name || "账号详情") : react.createElement("span", { className: cssMap.title }, "账号详情"),
|
||||
toast ? react.createElement("span", { className: cssMap.pagerInfo }, toast) : null),
|
||||
infoCard || tableArea),
|
||||
benchmarkRow && react.createElement("div", { className: cssMap.mask, onClick: () => setBenchmarkRow(null) },
|
||||
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
|
||||
react.createElement("div", { className: cssMap.modalTitle }, "确认改写脚本"),
|
||||
react.createElement("div", { className: cssMap.modalText },
|
||||
react.createElement("div", null, "视频:", benchmarkRow.video_title || "-"),
|
||||
react.createElement("div", null, "视频ID:", benchmarkRow.aweme_id || "-"),
|
||||
react.createElement("div", null, `将基于账号设定与该视频选题,在会话 dsh_rewrite 中创作一条全新的故事脚本(非分镜、不对标分析)。同一会话串行执行,运行中不可再次触发。`)),
|
||||
react.createElement("div", { className: cssMap.modalBtns },
|
||||
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setBenchmarkRow(null) }, "取消"),
|
||||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: confirmBenchmark }, "确认创作")))),
|
||||
analyzeRow && react.createElement("div", { className: cssMap.mask, onClick: () => setAnalyzeRow(null) },
|
||||
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
|
||||
react.createElement("div", { className: cssMap.modalTitle }, "确认解析视频"),
|
||||
react.createElement("div", { className: cssMap.modalText },
|
||||
react.createElement("div", null, "视频:", analyzeRow.video_title || "-"),
|
||||
react.createElement("div", null, "视频ID:", analyzeRow.aweme_id || "-"),
|
||||
react.createElement("div", null, "将调用抖音账号分析技能解析本视频(MCP 解析,约 3-5 分钟),解析结果保存到本地并写入数据库。确认解析?")),
|
||||
react.createElement("div", { className: cssMap.modalBtns },
|
||||
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setAnalyzeRow(null) }, "取消"),
|
||||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: submitAnalyze }, "确认解析")))),
|
||||
personaRow && react.createElement("div", { className: cssMap.mask, onClick: () => setPersonaRow(null) },
|
||||
react.createElement("div", { className: cssMap.modal, onClick: (e) => e.stopPropagation() },
|
||||
react.createElement("div", { className: cssMap.modalTitle }, "确认提炼账号设定"),
|
||||
react.createElement("div", { className: cssMap.modalText },
|
||||
react.createElement("div", null, "账号:", personaRow.account_name || "-"),
|
||||
react.createElement("div", null, "将调用抖音账号分析技能执行提炼账号设定(功能三:筛选 TOP4 视频 → MCP 解析 → 生成人设卡,约 10-20 分钟),结果保存到本地并写入数据库。确认执行?")),
|
||||
react.createElement("div", { className: cssMap.modalBtns },
|
||||
react.createElement("button", { type: "button", className: cssMap.btn, onClick: () => setPersonaRow(null) }, "取消"),
|
||||
react.createElement("button", { type: "button", className: cssMap.btn + " " + cssMap.btnPrimary, onClick: submitPersonaExtract }, "确认提炼")))));
|
||||
}
|
||||
//#endregion
|
||||
//#region plugin entry:注册到 MCN 工作台入口注册表(hidden:不显示在工作台网格,仅程序化导航打开)
|
||||
const inject = [];
|
||||
function apply() {
|
||||
window.__mcnEntries = window.__mcnEntries || [];
|
||||
// 幂等:避免插件热重载重复注册
|
||||
if (!window.__mcnEntries.some((e) => e.id === "douyin-account-detail")) {
|
||||
window.__mcnEntries.push({
|
||||
id: "douyin-account-detail",
|
||||
icon: IconChart,
|
||||
title: "账号详情",
|
||||
desc: "账号信息与视频列表(从账号列表进入)",
|
||||
hidden: true,
|
||||
component: AccountDetailPage,
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
exports.apply = apply;
|
||||
exports.inject = inject;
|
||||
return module.exports;
|
||||
}
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// dsh-plugin-douyin-account-detail 宿主端入口
|
||||
// 纯客户端 UI 插件:详情页通过 ./client 导出加载,
|
||||
// 数据复用 dsh-plugin-mcn 服务端路由(/mcn/api/account/videos)。
|
||||
// 服务端无需注册路由,仅需一个空 apply 以满足 cordis 插件契约。
|
||||
function apply() {}
|
||||
|
||||
const inject = [];
|
||||
|
||||
export { apply, inject };
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "dsh-plugin-douyin-account-detail",
|
||||
"version": "0.1.0",
|
||||
"description": "抖音账号详情(账号信息 + 视频列表分页) - MCN 工作台功能插件",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"cordis.patch.yml"
|
||||
],
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": []
|
||||
},
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
+9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: mcn-nav
|
||||
name: dsh-plugin-mcn
|
||||
+59
@@ -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` 与本文档,再改代码;回归对照本表逐行核销。
|
||||
+67
@@ -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。
|
||||
+3046
File diff suppressed because it is too large.
Load diff
+87
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
+3096
File diff suppressed because it is too large.
Load diff
+61
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+520
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
nodeLinker: hoisted
|
||||
autoInstallPeers: false
|
||||
Reference in new issue
Block a user