feat(MCN工作台): 数据功能页+图标扁平化+多项UI优化

- 工作台大版本升级:hash 路由 + 6 数据功能页(周榜/热门账号/账号详情/视频详情/AI写脚本/AI写作复盘),只读复用 dsh 库副本 + 抖音榜单 JSON
- 数据库隔离:复制 dsh 库到 mcn-work-shop/,与 dsh 原库各访问各的(已 gitignore)
- 图标全面扁平化:Lucide 线性 SVG,功能图标亮色区分,文字标题去图标
- 首页重构:功能入口卡 + AI写剧本快捷卡片(2列/8个/5行预览) + 独立帮助页 + header 精简
- 新增设计技能 impeccable + taste-skill(生成网页时参考)
- 修复:fmtTime 跨 script 全局冲突(P0)、弹窗关闭、外链新标签打开等
This commit is contained in:
maogeigei committed 2026-08-27 12:33:57 +08:00
1 parent f025ad3b86
commit 5f5b4f1cb7
120 files changed
+44528 -1

No files matched your search

@@ -0,0 +1,302 @@
// MCN工作台 - 数据访问层(只读复用 dsh 环境数据)
// 数据源:工作台目录 mcn-plugin.db(dsh 库副本,node:sqlite 只读)+ D:\dshworkspace\抖音榜单\*.json
// 与 dsh 原库(~/.dsh/mcn-plugin.db)隔离:两边各访问各的,互不影响
// 页面逻辑参照 dsh-plugin-mcn 重新开发,仅复用数据库 schema 与榜单文件格式
'use strict';
const { DatabaseSync } = require('node:sqlite');
const path = require('node:path');
const fs = require('node:fs');
// 工作台自有数据库副本(与 dsh 原库隔离)
const DSH_DB = path.join(__dirname, 'mcn-plugin.db');
// 周榜目录:默认 dsh 工作区;可用环境变量 MCN_RANKING_DIR 覆盖
const RANKING_DIR = process.env.MCN_RANKING_DIR || 'D:\\dshworkspace\\抖音榜单';
let _db = null;
let _dbTried = false;
function getDb() {
if (_db) return _db;
if (_dbTried) return null;
_dbTried = true;
try {
if (!fs.existsSync(DSH_DB)) return null;
_db = new DatabaseSync(DSH_DB, { readOnly: true });
} catch (e) { _db = null; }
return _db;
}
function dbAvailable() { return getDb() !== null; }
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
const fmt = (v) => (v == null || v === '') ? null : v;
/* ================= 账号 ================= */
function listTracks() {
const d = getDb(); if (!d) return [];
const rows = d.prepare(`SELECT DISTINCT track FROM hot_accounts WHERE track IS NOT NULL AND track != '' AND del_flag=0 ORDER BY track`).all();
return rows.map((r) => r.track);
}
function listAccounts({ page = 1, pageSize = 20, search = '', track = '', type = 'all' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = ['del_flag=0'];
const params = [];
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); }
if (type === 'signed' || type === 'external') { where.push('account_type = ?'); params.push(type); }
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
const total = d.prepare(`SELECT COUNT(*) c FROM hot_accounts ${whereSql}`).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 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`;
const items = d.prepare(`SELECT h.*, (SELECT COUNT(*) FROM account_videos v WHERE v.account_id = h.id) AS video_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 };
}
function listAccountVideos(accountId, page = 1, pageSize = 20) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(accountId);
if (!acc) throw new Error('账号不存在');
const total = d.prepare(`SELECT COUNT(*) 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 };
}
function getPersona(accountId) {
const d = getDb(); if (!d) return null;
return d.prepare(`SELECT content_json AS content, analysis_time FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(accountId) || null;
}
function getAccountAnalysis(accountId) {
const d = getDb(); if (!d) return null;
return d.prepare(`SELECT content_json AS content, analysis_time FROM account_analysis WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(accountId) || null;
}
/* ================= 视频 ================= */
function listVideos({ page = 1, pageSize = 20, search = '', parsed = '' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = [`v.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY publish_time DESC, id DESC) rn FROM account_videos WHERE like_count >= 300000 AND comment_count >= 10000) t WHERE rn <= 10)`];
const params = [];
if (search) { where.push('(v.video_title LIKE ? OR h.account_name LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
if (parsed === '1' || parsed === '0') {
const cond = `(v.id IN (SELECT video_id FROM account_video_source) OR v.id IN (SELECT video_id FROM account_video_analysis))`;
where.push(parsed === '1' ? cond : `NOT (${cond})`);
}
const w = 'WHERE ' + where.join(' AND ');
const total = d.prepare(`SELECT COUNT(*) c FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.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 v.*, h.account_name,
SUM(v.like_count) OVER (PARTITION BY v.account_id) AS acc_likes,
(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,
(SELECT COUNT(*) FROM rewrite_log r WHERE r.video_id=v.id AND r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '') AS rewrite_count
FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id
${w} ORDER BY acc_likes DESC, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
`).all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
function getVideoDetail({ videoId, awemeId }) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
let video = null;
if (videoId) {
video = d.prepare(`SELECT v.*, h.account_name FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id WHERE v.id=?`).get(videoId);
} else if (awemeId) {
video = d.prepare(`SELECT v.*, h.account_name FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id WHERE v.aweme_id=?`).get(String(awemeId));
}
if (!video) throw new Error('视频不存在');
const analysis = d.prepare(`SELECT content_json AS content, summary, analysis_time FROM account_video_analysis WHERE aweme_id=? OR video_id=? ORDER BY id DESC LIMIT 1`).get(String(video.aweme_id), video.id) || null;
// MCP 解析内容:account_video_source.content_json(content.json 全文)+ analysis_json(analysis.json 全文)
let parse = { found: false, content: null, analysis: null };
const src = 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(video.id, String(video.aweme_id));
if (src && src.content_json) {
try {
const jd = JSON.parse(src.content_json);
parse.content = typeof jd.content === 'string' ? jd.content : null;
parse.found = true;
if (src.analysis_json) {
try { parse.analysis = JSON.parse(src.analysis_json); } catch (e) {}
}
} catch (e) { parse.content = src.content_json; parse.found = true; }
}
return { video, analysis, parse };
}
function getVideoScript(videoId) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
let source = null;
const src = d.prepare(`SELECT content_json FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?) ORDER BY id DESC LIMIT 1`).get(videoId, videoId);
if (src && src.content_json) {
try {
const j = JSON.parse(src.content_json);
const c = String(j.content || j.script || src.content_json);
const idx = c.indexOf('# 视频脚本');
source = idx >= 0 ? c.slice(idx) : c;
} catch (e) { source = src.content_json; }
}
const rewrites = d.prepare(`SELECT id, script_text, created_time FROM rewrite_log WHERE video_id=? AND script_text IS NOT NULL AND script_text <> '' ORDER BY id DESC`).all(videoId);
return { source, rewrites };
}
/* ================= 改写 / 分镜 / 复盘 ================= */
function listRewrites(videoId) {
const d = getDb(); if (!d) return { items: [] };
const rows = videoId > 0
? d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log WHERE video_id=? ORDER BY id DESC`).all(videoId)
: d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log ORDER BY id DESC LIMIT 200`).all();
const revCols = `id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time`;
const aiRev = new Map(), cmpRev = new Map(), srcRev = new Map();
const ids = rows.map((r) => r.id);
const vidIds = [...new Set(rows.map((r) => r.video_id).filter((v) => v > 0))];
if (ids.length) {
for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type IN ('ai','compare') AND rewrite_id IN (${ids.map(() => '?').join(',')}) ORDER BY id DESC`).all(...ids)) {
if (rv.type === 'ai') { if (!aiRev.has(rv.rewrite_id)) aiRev.set(rv.rewrite_id, rv); }
else if (!cmpRev.has(rv.rewrite_id)) cmpRev.set(rv.rewrite_id, rv);
}
}
if (vidIds.length) {
for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type='source' AND video_id IN (${vidIds.map(() => '?').join(',')}) ORDER BY id DESC`).all(...vidIds)) {
if (!srcRev.has(rv.video_id)) srcRev.set(rv.video_id, rv);
}
}
const runningKeys = new Set();
for (const rr of d.prepare(`SELECT type, rewrite_id, video_id, status FROM script_review WHERE status='running'`).all()) {
runningKeys.add(rr.type === 'source' ? 'source:' + rr.video_id : rr.type + ':' + rr.rewrite_id);
}
for (const it of rows) {
it.review_ai = aiRev.get(it.id) || null;
it.review_compare = cmpRev.get(it.id) || null;
it.review_source = (it.video_id && srcRev.get(it.video_id)) || null;
it.review_running = runningKeys.has('ai:' + it.id) || runningKeys.has('compare:' + it.id) || (it.video_id && runningKeys.has('source:' + it.video_id));
}
return { items: rows };
}
function listStoryboards(videoId) {
const d = getDb(); if (!d) return [];
return d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time, updated_time FROM storyboard_log WHERE video_id=? ORDER BY id DESC`).all(videoId);
}
function getReviewDetail(id) {
const d = getDb(); if (!d) return null;
return d.prepare(`SELECT * FROM script_review WHERE id=?`).get(id) || null;
}
function getReviewFull(rewriteId) {
const d = getDb(); if (!d) return null;
const rw = d.prepare(`SELECT video_id FROM rewrite_log WHERE id=?`).get(rewriteId);
const videoId = rw ? rw.video_id : 0;
const source = videoId ? d.prepare(`SELECT * FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(videoId) : null;
const ai = d.prepare(`SELECT * FROM script_review WHERE type='ai' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(rewriteId);
const compare = d.prepare(`SELECT * FROM script_review WHERE type='compare' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(rewriteId);
return { source: source || null, ai: ai || null, compare: compare || null };
}
function getStats() {
const d = getDb(); if (!d) return null;
const c = (sql) => d.prepare(sql).get().c;
return {
accounts: c(`SELECT COUNT(*) c FROM hot_accounts WHERE del_flag=0`),
analyses: c(`SELECT COUNT(*) c FROM account_video_analysis`),
rewrites: c(`SELECT COUNT(*) c FROM rewrite_log WHERE status='done' AND script_text IS NOT NULL AND script_text <> ''`),
reviews: c(`SELECT COUNT(*) c FROM script_review WHERE status='done'`),
writingScore: c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`),
};
}
// 最近 AI 写剧本(首页快捷卡片):status=done 且有内容,按 id 倒序取前 N 条
function listRecentScripts(limit = 4) {
const d = getDb(); if (!d) return [];
const n = Math.max(1, Math.min(Number(limit) || 4, 20));
return d.prepare(`
SELECT r.id, r.title, r.script_text, r.created_time, r.video_id, r.account_id,
h.account_name, v.video_title
FROM rewrite_log r
LEFT JOIN hot_accounts h ON h.id = r.account_id
LEFT JOIN account_videos v ON v.id = r.video_id
WHERE r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> ''
ORDER BY r.id DESC LIMIT ?
`).all(n);
}
/* ================= 周榜 ================= */
function rankingMeta() {
const empty = { dates: [], categories: [] };
if (!fs.existsSync(RANKING_DIR)) return empty;
const files = fs.readdirSync(RANKING_DIR).filter((f) => /^抖音周榜_.+_\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse();
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.match(/_(\d{4}-\d{2}-\d{2})\.json$/)[1]))];
const categories = [...new Set(files.map((f) => { const m = f.match(/^抖音周榜_(.+)_\d{4}-\d{2}-\d{2}\.json$/); return m ? m[1] : null; }).filter(Boolean))];
return { dates, categories };
}
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 getRanking({ date = '', category = '', sortBy = 'rank', order = '', page = 1, pageSize = 20 } = {}) {
const empty = { dates: [], categories: [], category: '', date: '', total: 0, items: [] };
if (!fs.existsSync(RANKING_DIR)) return empty;
const files = fs.readdirSync(RANKING_DIR).filter((f) => /^抖音周榜_.+_\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse();
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.match(/_(\d{4}-\d{2}-\d{2})\.json$/)[1]))];
const categories = [...new Set(files.map((f) => { const m = f.match(/^抖音周榜_(.+)_\d{4}-\d{2}-\d{2}\.json$/); return m ? m[1] : null; }).filter(Boolean))];
const selDate = date || dates[0];
const selFiles = files.filter((f) => f.includes(selDate) && (!category || f.includes('_' + category + '_')));
let items = [];
for (const f of selFiles) {
try { items = items.concat(JSON.parse(fs.readFileSync(path.join(RANKING_DIR, f), 'utf8')).list || []); } catch (e) {}
}
const orderDir = order === 'asc' ? 'asc' : (order === 'desc' ? 'desc' : (sortBy === 'rank' ? 'asc' : 'desc'));
items.sort((a, b) => {
const va = sortBy === 'rank' ? num(a.rank) : sortBy === 'comprehensiveScore' ? num(a.comprehensiveScore) : parseNum(a[sortBy]);
const vb = sortBy === 'rank' ? num(b.rank) : sortBy === 'comprehensiveScore' ? num(b.comprehensiveScore) : parseNum(b[sortBy]);
return orderDir === '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 d = getDb();
const secOf = (url) => { const m = String(url || '').match(/\/user\/([^/?#]+)/); return m ? m[1] : ''; };
const pageItems = items.slice(offset, offset + limit).map((row) => {
let hit = null;
if (d) {
const sec = secOf(row.profileUrl);
hit = sec
? d.prepare(`SELECT id FROM hot_accounts WHERE sec_uid=? OR account_name=? LIMIT 1`).get(sec, row.accountName)
: d.prepare(`SELECT id FROM hot_accounts WHERE account_name=? LIMIT 1`).get(row.accountName);
}
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 };
}
module.exports = {
dbAvailable, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR,
};
@@ -0,0 +1,490 @@
/* MCN工作台 - 前端核心(原生 JS,零依赖)
路由框架 + 首页 + 产出浏览 + markdown 渲染 + 复制/提示工具
数据功能页(周榜/账号/视频/脚本/复盘)见 data-pages.js */
'use strict';
/* ================= 工具 ================= */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmtSize = (n) => { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; };
const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'table', txt: 'file-text', log: 'file-text', bak: 'file' };
/* ================= 扁平化线性图标(Lucide 风格:24 viewBox / stroke currentColor / 无底色) ================= */
const ICON_PATHS = {
'film': '<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="m12.4 3.4 3.1 4"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'users': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'pen': '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'flask': '<path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
'lightbulb': '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
'refresh': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
'chart': '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
'folder': '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
'folder-open': '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
'file-text': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
'file-code': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
'table': '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
'file': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
};
// 功能图标亮色映射(不同功能不同亮色;未列出的保持 currentColor 中性)
const ICON_COLORS = {
'trending-up': '#e8590c', 'users': '#2f6fed', 'pen': '#8250df', 'flask': '#1a7f37',
'lightbulb': '#d99a2b', 'refresh': '#0f7f8f', 'chart': '#d6336c', 'wrench': '#5b6470',
};
function svgIcon(name, size = 16) {
const p = ICON_PATHS[name] || ICON_PATHS['file'];
const color = ICON_COLORS[name] ? ` style="color:${ICON_COLORS[name]}"` : '';
return `<svg class="ic" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${color}>${p}</svg>`;
}
// 把 [data-icon] 占位元素替换为 SVG 图标(静态 HTML 里的 header 按钮等)
function initIcons() {
document.querySelectorAll('[data-icon]').forEach((el) => {
const size = Number(el.dataset.size) || 16;
el.innerHTML = svgIcon(el.dataset.icon, size);
});
}
async function api(path, opts) {
const res = await fetch(path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ('请求失败 ' + res.status));
return data;
}
async function copyText(text) {
try { await navigator.clipboard.writeText(text); return true; }
catch (e) {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok;
} catch (e2) { return false; }
}
}
let _toastTimer = null;
function showToast(msg) {
let t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg; t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 1800);
}
/* ================= 迷你 Markdown 渲染器 ================= */
function inlineMd(s) {
let out = esc(s);
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
return out;
}
function renderMarkdown(src) {
const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
let html = '';
let inCode = false, codeBuf = [], inTable = false, tableBuf = [], listType = null;
const flushTable = () => { if (!tableBuf.length) return; const rows = tableBuf.map((r) => '<tr>' + r.split('|').slice(1, -1).map((c) => '<td>' + inlineMd(c.trim()) + '</td>').join('') + '</tr>').join(''); html += '<table>' + rows + '</table>'; tableBuf = []; };
const flushList = () => { if (listType) { html += '</' + listType + '>'; listType = null; } };
for (const raw of lines) {
if (/^```/.test(raw)) {
if (inCode) { html += '<pre><code>' + esc(codeBuf.join('\n')) + '</code></pre>'; inCode = false; codeBuf = []; }
else { flushTable(); flushList(); inCode = true; }
continue;
}
if (inCode) { codeBuf.push(raw); continue; }
const line = raw.replace(/\s+$/, '');
if (!line.trim()) { flushTable(); flushList(); continue; }
if (/^\|.+\|$/.test(line)) { if (/^\|[\s:|-]+\|$/.test(line)) continue; if (!inTable) { flushList(); flushTable(); inTable = true; } tableBuf.push(line); continue; }
else if (inTable) { flushTable(); inTable = false; }
const h = line.match(/^(#{1,4})\s+(.*)/);
if (h) { flushList(); const n = h[1].length; html += `<h${n}>${inlineMd(h[2])}</h${n}>`; continue; }
if (/^>\s?/.test(line)) { flushList(); html += '<blockquote>' + inlineMd(line.replace(/^>\s?/, '')) + '</blockquote>'; continue; }
if (/^(-{3,}|\*{3,})$/.test(line)) { flushList(); html += '<hr>'; continue; }
if (/^[-*]\s+/.test(line)) { if (listType !== 'ul') { flushList(); html += '<ul>'; listType = 'ul'; } html += '<li>' + inlineMd(line.replace(/^[-*]\s+/, '')) + '</li>'; continue; }
if (/^\d+\.\s+/.test(line)) { if (listType !== 'ol') { flushList(); html += '<ol>'; listType = 'ol'; } html += '<li>' + inlineMd(line.replace(/^\d+\.\s+/, '')) + '</li>'; continue; }
flushList();
html += '<p>' + inlineMd(line) + '</p>';
}
flushTable(); flushList();
if (inCode) html += '<pre><code>' + esc(codeBuf.join('\n')) + '</code></pre>';
return html;
}
function prettyJson(text) { try { return JSON.stringify(JSON.parse(text), null, 2); } catch (e) { return text; } }
/* ================= 技能导航数据 ================= */
const FUNC_GROUPS = [
{ icon: 'lightbulb', title: '找选题 & 灵感', desc: '爆款选题生成、热点结合选题', qa: [
['"帮我想几个美妆赛道的爆款选题"', '按赛道生成差异化选题'],
['"最近有什么热点可以做学习类选题"', '联网搜索热点 + 事件库查询灵感'],
]},
{ icon: 'pen', title: '写脚本', desc: '全流程创作、带货植入、分镜展开、替换事件重写(可从账号分析衔接)', qa: [
['"帮我写一个美妆种草脚本,30秒"', '人设→选题→框架→大纲→脚本全流程 + 质检'],
['"基于「旧梦留声机」账号设定,替换事件写一个脚本"', '读账号设定,按账号套路写全新事件脚本'],
['"把刚写的脚本展开成分镜"', '按分镜技能生成镜头级描述'],
]},
{ icon: 'refresh', title: '账号分析', desc: '提炼账号设定 · 分析视频 · 分析账号', qa: [
['"帮我设计一个三农赛道的达人IP"', '提炼账号设定(人设卡)'],
['"帮我拆解这个抖音视频"', '解析视频,拆解钩子/节奏/情绪/选题'],
['"分析一下这个账号的内容套路"', '账号内容结构拆解 + 定位诊断'],
]},
{ icon: 'chart', title: '诊断 & 评分', desc: '四维度评分(爆款要素/人设匹配/可拍摄性/合规性,100分制)', qa: [
['"帮我诊断这个脚本"', '四维度评分 + 问题清单和修改建议'],
['"检查这个带货脚本有没有违规风险"', '合规专项审查(广告法/违禁词逐项扣分)'],
]},
{ icon: 'wrench', title: '工具接口', desc: 'MCP 解析抖音链接 / 本地视频 / 拉取账号设定与代表作', qa: [
['"解析这个抖音链接"', 'MCP 自动解析视频内容'],
['"解析这个本地视频文件"', '读取本地视频并分析'],
]},
];
/* ================= 首页 ================= */
function renderHome() {
const cards = [
{ icon: 'trending-up', title: '抖音周榜', desc: '本地周榜数据 · 赛道/日期筛选', hash: '#/ranking' },
{ icon: 'users', title: '热门账号', desc: '签约/外部账号 + 视频列表', hash: '#/accounts' },
{ icon: 'pen', title: 'AI写脚本', desc: '源脚本 vs AI脚本对比 + 分镜', hash: '#/rewrites' },
{ icon: 'flask', title: 'AI写作复盘', desc: '评分维度 + 复盘报告', hash: '#/reviews' },
];
return `
<div class="home">
<div class="data-cards">
${cards.map((c) => `<button class="data-card" data-hash="${c.hash}">
<span class="data-card-icon">${svgIcon(c.icon, 22)}</span>
<span class="data-card-title">${c.title}</span>
<span class="data-card-desc">${c.desc}</span>
</button>`).join('')}
</div>
<div class="recent-scripts">
<div class="recent-head"><h3>AI写剧本(最近)</h3><a class="link" href="#/rewrites">查看全部 →</a></div>
<div id="recentScriptsList" class="recent-list"><span class="dim">加载中…</span></div>
</div>
</div>`;
}
/* ================= 帮助页(功能帮助 + 快速提问) ================= */
function renderHelp() {
return `
<div class="home">
<div class="intro">
<p>直接对 WorkBuddy 说需求即可,无需记指令。<strong>点击任意问法即可复制</strong>,粘贴到对话框回车执行:</p>
</div>
<div class="func-grid">
${FUNC_GROUPS.map((g) => `
<div class="func-card">
<h3>${svgIcon(g.icon, 18)} ${g.title}</h3>
<div class="desc">${g.desc}</div>
<div class="qa">
${g.qa.map(([q, a]) => `<div class="qa-row copyable" data-q="${esc(q)}" title="点击复制此问法"><span class="q">${esc(q)}</span><span class="a">→ ${esc(a)}</span></div>`).join('')}
</div>
</div>`).join('')}
</div>
<div class="modes">
<h3>三种创作模式</h3>
<table class="mode-table">
<tr><td class="mode-name">${svgIcon('zap')} 自动(默认)</td><td>静默执行全流程,只交付最终脚本</td></tr>
<tr><td class="mode-name">${svgIcon('users')} 共创</td><td>每个关键节点先确认再继续</td></tr>
<tr><td class="mode-name">${svgIcon('wrench')} 开发</td><td>每个步骤都落盘保存中间产物</td></tr>
</table>
<p class="mode-hint">开始时说一声即可切换,如「用共创模式写一个脚本」。</p>
</div>
</div>`;
}
/* ================= 首页 AI写剧本 快捷卡片 ================= */
function fmtDate(t) {
if (!t) return '';
const d = new Date(t);
if (Number.isNaN(d.getTime())) return String(t);
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
// 剧本正文预览:去空行/标题行,取前 n 行(每行不截断,交给 CSS ellipsis 撑满卡片宽度)
function previewLines(text, n = 5) {
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^#{1,4}\s/.test(l));
return lines.slice(0, n);
}
let _recentScriptsCache = {};
async function loadRecentScripts() {
const el = $('#recentScriptsList');
if (!el) return;
try {
const d = await api('/api/dsh/recent-scripts?limit=8');
const items = (d.items || []);
if (!items.length) { el.innerHTML = '<span class="dim">暂无 AI 写剧本</span>'; return; }
_recentScriptsCache = {};
el.innerHTML = items.map((s) => {
_recentScriptsCache[s.id] = s;
const title = s.title || s.video_title || '未命名剧本';
const meta = [s.account_name, s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | ');
const preview = previewLines(s.script_text, 5);
return `<button class="recent-card" data-script="${s.id}" title="点击查看完整剧本">
<span class="recent-card-title">${esc(title)}</span>
<span class="recent-card-preview">${preview.map((l) => `<span class="recent-preview-line">${esc(l)}</span>`).join('')}</span>
<span class="recent-card-meta">${esc(meta)}</span>
</button>`;
}).join('');
el.querySelectorAll('.recent-card').forEach((b) => b.addEventListener('click', () => {
const s = _recentScriptsCache[b.dataset.script];
if (s) openRecentScript(s);
}));
} catch (e) { el.innerHTML = '<span class="dim">' + esc(e.message) + '</span>'; }
}
function openRecentScript(s) {
const title = s.title || s.video_title || 'AI 写剧本';
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>${svgIcon('pen')} ${esc(title)}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="recent-meta">${esc([s.account_name, s.video_title, s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | '))}</div>
<div class="rw-content md-body" style="max-height:70vh">${renderMarkdown(s.script_text || '(暂无内容)')}</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
}
/* ================= 产出浏览 ================= */
const fileState = { roots: { defaultRoot: '', extraRoots: [], defaultExists: false }, currentRoot: '', selectedPath: '', tree: [], expandMap: new Set(), search: '' };
function renderFiles() {
return `
<div class="files-layout">
<aside class="sidebar">
<div class="sidebar-top">
<select id="rootSelect" title="项目地址"></select>
<input id="fileSearch" type="search" placeholder="搜索文件名…">
</div>
<div id="fileTree" class="file-tree"><div class="tree-empty">加载中…</div></div>
</aside>
<div class="content-view">
<div id="previewEmpty" class="preview-empty">
<p>👈 从左侧选择文件查看</p>
<p class="dim">支持 Markdown / JSON / 文本在线预览;表格类(xlsx)点击下载后用本地软件打开</p>
</div>
<div id="preview" class="preview" hidden></div>
</div>
</div>`;
}
async function initFiles() {
const r = await api('/api/roots');
fileState.roots = r;
const sel = $('#rootSelect');
const opts = [];
if (r.defaultExists) opts.push(`<option value="${esc(r.defaultRoot)}">📁 桌面 MCNSkill项目(默认)</option>`);
else opts.push(`<option value="">📁 桌面 MCNSkill项目(目录不存在)</option>`);
r.extraRoots.forEach((p) => opts.push(`<option value="${esc(p)}">📂 ${esc(p)}</option>`));
sel.innerHTML = opts.join('');
const saved = localStorage.getItem('mcnws_root');
if (saved && [...sel.options].some((o) => o.value === saved)) sel.value = saved;
fileState.currentRoot = sel.value;
sel.addEventListener('change', () => {
fileState.currentRoot = sel.value;
localStorage.setItem('mcnws_root', fileState.currentRoot);
loadTree();
});
$('#fileSearch').addEventListener('input', (e) => {
clearTimeout(fileState._st);
fileState._st = setTimeout(() => { fileState.search = e.target.value; renderTree(); }, 200);
});
await loadTree();
}
async function loadTree() {
const treeEl = $('#fileTree');
if (!fileState.currentRoot) { treeEl.innerHTML = '<div class="tree-empty">默认项目地址不存在,请在设置中添加项目地址</div>'; return; }
treeEl.innerHTML = '<div class="tree-empty">加载中…</div>';
try {
const data = await api('/api/tree?root=' + encodeURIComponent(fileState.currentRoot));
fileState.tree = data.tree;
fileState.expandMap.clear();
fileState.tree.forEach((n) => { if (n.type === 'dir') fileState.expandMap.add(n.path); });
renderTree();
} catch (e) { treeEl.innerHTML = '<div class="tree-empty">' + esc(e.message) + '</div>'; }
}
function filterNodes(nodes) {
const kw = fileState.search.trim().toLowerCase();
if (!kw) return nodes;
const out = [];
for (const n of nodes) {
if (n.type === 'file') { if (n.name.toLowerCase().includes(kw)) out.push(n); }
else { const children = filterNodes(n.children || []); if (children.length) out.push({ ...n, children }); }
}
return out;
}
function nodeHtml(n, depth) {
if (n.type === 'dir') {
const open = fileState.expandMap.has(n.path);
const children = open ? (n.children || []) : [];
return `<div class="tree-node"><div class="tree-row" data-path="${esc(n.path)}" data-type="dir"><span class="caret">${open ? '▾' : '▸'}</span><span class="icon">${svgIcon(open ? 'folder-open' : 'folder', 15)}</span><span class="fname">${esc(n.name)}</span></div>${open && children.length ? `<div class="tree-children">${children.map((c) => nodeHtml(c, depth + 1)).join('')}</div>` : ''}</div>`;
}
const ext = (n.ext || '').slice(1);
return `<div class="tree-row" data-path="${esc(n.path)}" data-type="file"><span class="caret"></span><span class="icon">${svgIcon(EXT_ICON[ext] || 'file', 15)}</span><span class="fname ${'ext-' + (ext || 'txt')}">${esc(n.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--dim)">${fmtSize(n.size)}</span></div>`;
}
function renderTree() {
const treeEl = $('#fileTree');
const nodes = filterNodes(fileState.tree);
if (!nodes.length) { treeEl.innerHTML = '<div class="tree-empty">' + (fileState.search ? '无匹配文件' : '空目录') + '</div>'; return; }
treeEl.innerHTML = '<div class="tree-root">' + nodes.map((n) => nodeHtml(n, 0)).join('') + '</div>';
treeEl.querySelectorAll('.tree-row').forEach((row) => row.addEventListener('click', () => onRowClick(row)));
}
function onRowClick(row) {
const p = row.dataset.path, type = row.dataset.type;
if (type === 'dir') { if (fileState.expandMap.has(p)) fileState.expandMap.delete(p); else fileState.expandMap.add(p); renderTree(); }
else {
document.querySelectorAll('.tree-row').forEach((r) => r.classList.remove('selected'));
row.classList.add('selected');
fileState.selectedPath = p;
openFile(p);
}
}
async function openFile(p) {
$('#previewEmpty').hidden = true;
const pv = $('#preview');
pv.hidden = false; pv.innerHTML = '<p class="dim">加载中…</p>';
try {
const d = await api('/api/read?path=' + encodeURIComponent(p));
let body = '';
if (d.ext === '.md' || d.ext === '.markdown') body = '<div class="md-body">' + renderMarkdown(d.content) + '</div>';
else if (d.ext === '.json') body = '<div class="json-view"><pre>' + esc(prettyJson(d.content)) + '</pre></div>';
else body = '<div class="md-body"><pre style="white-space:pre-wrap">' + esc(d.content) + '</pre></div>';
pv.innerHTML = `<div class="file-head"><strong>${esc(d.name)}</strong><span class="fpath">${esc(d.path)}</span><span class="fmeta">${fmtSize(d.size)}</span><a class="btn-download" href="/api/download?path=${encodeURIComponent(p)}">⬇ 下载</a></div>${body}`;
} catch (e) { pv.innerHTML = '<p class="dim">' + esc(e.message) + '</p>'; }
}
/* ================= 设置弹窗 ================= */
function renderRootList() {
const list = $('#rootList');
const roots = fileState.roots;
list.innerHTML = (roots.extraRoots || []).map((p) => `<div class="root-item"><span class="rp">${esc(p)}</span><button class="rm" data-rm="${esc(p)}" title="移除">${svgIcon('x', 13)}</button></div>`).join('') || '<div class="dim" style="font-size:12px">暂无自定义目录</div>';
list.querySelectorAll('.rm').forEach((btn) => btn.addEventListener('click', async () => {
const p = btn.dataset.rm;
roots.extraRoots = roots.extraRoots.filter((x) => x !== p);
await saveRoots();
}));
}
async function saveRoots() {
try {
const r = await api('/api/roots', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extraRoots: fileState.roots.extraRoots }) });
fileState.roots.extraRoots = r.extraRoots;
$('#rootMsg').textContent = '✅ 已保存';
setTimeout(() => { $('#rootMsg').textContent = ''; }, 1500);
renderRootList();
} catch (e) { $('#rootMsg').textContent = '保存失败:' + e.message; }
}
function openSettings() { renderRootList(); $('#settingsModal').hidden = false; }
function closeSettings() { $('#settingsModal').hidden = true; }
/* ================= 路由 ================= */
const CRUMB_TITLES = { 'ranking': '抖音周榜', 'accounts': '热门账号', 'rewrites': 'AI写脚本', 'reviews': 'AI写作复盘' };
function parseHash() {
const h = (location.hash || '#/').replace(/^#/, '');
const parts = h.split('/').filter(Boolean);
return { name: parts[0] || '', param: parts[1] || '' };
}
function updateCrumbs(name, title) {
const c = $('#crumbs');
if (!name) { c.innerHTML = '<span class="crumb-home">MCN 工作台</span>'; return; }
c.innerHTML = `<a class="crumb-home" href="#/">MCN 工作台</a><span class="crumb-sep">/</span><span class="crumb-current">${esc(title || CRUMB_TITLES[name] || '功能页')}</span>`;
}
async function router() {
const { name, param } = parseHash();
const view = $('#view');
view.className = '';
if (name === '' || name === 'home') {
updateCrumbs('');
view.innerHTML = renderHome();
view.querySelectorAll('.data-card').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.hash; }));
loadRecentScripts();
return;
}
if (name === 'help') {
updateCrumbs('help', '功能帮助');
view.innerHTML = renderHelp();
view.querySelectorAll('.qa-row.copyable').forEach((row) => row.addEventListener('click', async () => {
const text = (row.dataset.q || '').replace(/^"|"$/g, '');
if (text) showToast((await copyText(text)) ? '✅ 已复制:' + text : '❌ 复制失败');
}));
return;
}
if (name === 'files') {
updateCrumbs('', ''); // 产出浏览也回到无面包屑态
view.innerHTML = renderFiles();
await initFiles();
return;
}
// 数据功能页
if (typeof window.DataPages !== 'undefined') {
const titles = { ranking: '抖音周榜', accounts: '热门账号', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: 'AI写作复盘' };
const pages = {
'ranking': () => window.DataPages.renderRanking(view),
'accounts': () => window.DataPages.renderAccounts(view),
'account': () => window.DataPages.renderAccountDetail(view, param),
'video': () => window.DataPages.renderVideoDetail(view, param),
'rewrites': () => window.DataPages.renderRewrites(view),
'reviews': () => window.DataPages.renderReviews(view),
};
if (pages[name]) {
updateCrumbs(name, titles[name]);
await pages[name]();
return;
}
}
// 未知路由 → 首页
location.hash = '#/';
}
window.addEventListener('hashchange', router);
$('#btnHome').addEventListener('click', () => { location.hash = '#/'; });
$('#btnHelp').addEventListener('click', () => { location.hash = '#/help'; });
$('#btnSettings').addEventListener('click', openSettings);
$('#btnCloseSettings').addEventListener('click', closeSettings);
$('#settingsModal').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeSettings(); });
$('#btnAddRoot').addEventListener('click', async () => {
const input = $('#newRoot');
const p = input.value.trim();
if (!p) return;
fileState.roots.extraRoots.push(p);
input.value = '';
await saveRoots();
});
$('#newRoot').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#btnAddRoot').click(); });
/* ================= 外链新标签打开(JS 强制 + 被拦截兜底复制) ================= */
document.addEventListener('click', (e) => {
const a = e.target.closest('a[data-ext]');
if (!a) return;
e.preventDefault();
const url = a.dataset.ext;
if (!url) return;
let w = null;
try { w = window.open(url, '_blank', 'noopener,noreferrer'); } catch (err) { w = null; }
if (!w) {
copyText(url);
showToast('⚠️ 新窗口被拦截,链接已复制,请粘贴到浏览器地址栏打开');
}
});
initIcons();
router();
@@ -0,0 +1,480 @@
/* MCN工作台 - 数据功能页(周榜/账号/视频/脚本/复盘,只读 + prompt 触发)
依赖 app.js 暴露的工具:esc/api/renderMarkdown/copyText/showToast/fmtSize */
'use strict';
const fmt = (v) => (v == null || v === '') ? '-' : String(v);
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 fmtTime = (t) => { if (!t) return '-'; const d = new Date(t); if (Number.isNaN(d.getTime())) return String(t); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; };
function pagerHtml(total, page, pageSize, onChange) {
const pages = Math.max(1, Math.ceil(total / pageSize));
return `<div class="pager"><span class="pager-total">共 ${total} 条</span><span class="pager-info">第 ${page} / ${pages} 页</span>
<button class="btn" data-pg="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>
<button class="btn" data-pg="${page + 1}" ${page >= pages ? 'disabled' : ''}>下一页</button></div>`;
}
function bindPager(container, onChange) {
container.querySelectorAll('[data-pg]').forEach((b) => b.addEventListener('click', () => { const p = Number(b.dataset.pg); if (p >= 1) onChange(p); }));
}
function toolbarBack() {
return `<button class="btn" data-back title="返回">←</button>`;
}
function bindBack(container) {
container.querySelectorAll('[data-back]').forEach((b) => b.addEventListener('click', () => { location.hash = '#/'; }));
}
/* 生成「触发 AI」的 prompt(WorkBuddy 无 AI 桥接,降级为复制到剪贴板) */
async function firePrompt(prompt, label) {
const ok = await copyText(prompt);
showToast(ok ? `✅ 已复制${label || '任务指令'},请粘贴到对话框执行` : '❌ 复制失败,请手动复制');
}
/* ================= 周榜 ================= */
async function renderRanking(view) {
let state = { date: '', category: '', sortBy: 'rank', order: 'asc', page: 1, pageSize: 20, dates: [], categories: [] };
const data = await api('/api/dsh/ranking?pageSize=20').catch((e) => ({ ok: false, error: e.message }));
if (!data.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}<span class="page-title">抖音周榜</span></div><div class="empty">${esc(data.error || '加载失败')}</div></div>`; bindBack(view); return; }
state.dates = data.dates || []; state.categories = data.categories || []; state.date = data.date; state.category = data.category;
async function load() {
const d = await api('/api/dsh/ranking?date=' + encodeURIComponent(state.date) + '&category=' + encodeURIComponent(state.category) + '&sortBy=' + state.sortBy + '&order=' + state.order + '&page=' + state.page + '&pageSize=' + state.pageSize);
const di = state.dates.indexOf(state.date);
const sortable = (key, label) => `<th class="sortable" data-sort="${key}">${label}${state.sortBy === key ? (state.order === 'asc' ? ' ↑' : ' ↓') : ''}</th>`;
const rows = (d.items || []).map((r) => `<tr>
<td>${r.rank ?? '-'}</td>
<td>${r.profileUrl ? `<a class="link" data-ext="${esc(r.profileUrl)}" title="在新标签打开账号主页">${esc(r.accountName)}</a>` : esc(r.accountName)}</td>
<td>${esc(r.category || '-')}</td>
<td>${fmt(r.comprehensiveScore)}</td>
<td>${fmt(r.followers)}</td>
<td>${fmt(r.newFans)}</td>
<td>${fmt(r.newLikes)}</td>
<td>${fmt(r.newComments)}</td>
<td>${fmt(r.newShares)}</td>
<td>${r.inDb ? `<a class="link" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm" data-import='${esc(JSON.stringify(r))}'>导入</button>`}</td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
<div class="page-toolbar">
${toolbarBack()}
<span class="page-title">抖音周榜</span>
<select id="rankCat" class="input-select" title="赛道筛选">
<option value="">全部赛道</option>
${state.categories.map((c) => `<option value="${esc(c)}" ${c === state.category ? 'selected' : ''}>${esc(c)}</option>`).join('')}
</select>
<button class="btn" id="rankPrev" ${di <= 0 ? 'disabled' : ''}>◀</button>
<span class="pager-info" title="选择日期">${esc(state.date)}</span>
<button class="btn" id="rankNext" ${di < 0 || di >= state.dates.length - 1 ? 'disabled' : ''}>▶</button>
</div>
<div class="table-wrap">
<table class="table">
<thead><tr>
${sortable('rank', '排名')}<th>账号</th><th>赛道</th>${sortable('comprehensiveScore', '综合得分')}${sortable('followers', '粉丝')}${sortable('newFans', '新增粉丝')}${sortable('newLikes', '新增点赞')}${sortable('newComments', '新增评论')}${sortable('newShares', '新增分享')}<th>功能</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="10" class="empty">暂无周榜数据</td></tr>'}</tbody>
</table>
</div>
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
</div>`;
bindBack(view);
view.querySelectorAll('.sortable').forEach((th) => th.addEventListener('click', () => {
const k = th.dataset.sort;
if (state.sortBy === k) state.order = state.order === 'asc' ? 'desc' : 'asc';
else { state.sortBy = k; state.order = k === 'rank' ? 'asc' : 'desc'; }
state.page = 1; load();
}));
view.querySelector('#rankCat').addEventListener('change', (e) => { state.category = e.target.value; state.page = 1; load(); });
view.querySelector('#rankPrev').addEventListener('click', () => { const i = state.dates.indexOf(state.date) - 1; if (i >= 0) { state.date = state.dates[i]; state.page = 1; load(); } });
view.querySelector('#rankNext').addEventListener('click', () => { const i = state.dates.indexOf(state.date) + 1; if (i < state.dates.length) { state.date = state.dates[i]; state.page = 1; load(); } });
bindPager(view, (p) => { state.page = p; load(); });
view.querySelectorAll('[data-import]').forEach((b) => b.addEventListener('click', () => {
const r = JSON.parse(b.dataset.import);
firePrompt(`请帮我把抖音账号「${r.accountName}」导入并分析(账号信息 + 视频列表):${r.profileUrl || ''}`, '导入指令');
}));
}
await load();
}
/* ================= 热门账号 ================= */
async function renderAccounts(view) {
let state = { type: 'all', search: '', track: '', page: 1, pageSize: 20 };
const tracks = (await api('/api/dsh/tracks').catch(() => ({ tracks: [] }))).tracks || [];
async function load() {
const d = await api('/api/dsh/accounts?type=' + state.type + '&search=' + encodeURIComponent(state.search) + '&track=' + encodeURIComponent(state.track) + '&page=' + state.page + '&pageSize=' + state.pageSize);
const badge = (t) => `<span class="badge ${t === 'external' ? 'badge-ext' : 'badge-signed'}">${t === 'external' ? '外部' : '签约'}</span>`;
const rows = (d.items || []).map((a) => `<tr>
<td>${a.favorite ? '⭐' : '☆'}</td>
<td>${badge(a.account_type)}</td>
<td><a class="link strong" href="#/account/${a.id}">${esc(a.account_name)}</a></td>
<td>${esc(a.track || '-')}</td>
<td>${a.is_ai === '是' ? '是' : '否'}</td>
<td>${fmt(a.followers)}</td>
<td>${fmt(a.total_likes)}</td>
<td>${fmt(a.works_count)}</td>
<td>${esc(a.location || '-')}</td>
<td>${fmt(a.age)}</td>
<td class="cell-content" title="${esc(a.bio || '')}">${esc((a.bio || '').slice(0, 50))}</td>
<td class="cell-content" title="${esc(a.content || '')}">${esc((a.content || '').slice(0, 50))}</td>
<td>${a.video_count ?? 0}</td>
<td><button class="btn btn-sm" data-create='${esc(a.account_name)}'>创作</button></td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
<div class="page-toolbar">
${toolbarBack()}
<span class="page-title">热门账号</span>
<select id="accType" class="input-select">
<option value="all" ${state.type === 'all' ? 'selected' : ''}>全部</option>
<option value="signed" ${state.type === 'signed' ? 'selected' : ''}>签约</option>
<option value="external" ${state.type === 'external' ? 'selected' : ''}>外部</option>
</select>
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
<button class="btn" id="accReset">重置</button>
</div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>收藏</th><th>类型</th><th>账号名</th><th>赛道</th><th>AI内容</th><th>粉丝</th><th>获赞</th><th>作品数</th><th>地区</th><th>年龄</th><th>简介</th><th>定位/内容</th><th>视频数</th><th>操作</th></tr></thead>
<tbody>${rows || '<tr><td colspan="14" class="empty">暂无账号数据</td></tr>'}</tbody>
</table>
</div>
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
</div>`;
bindBack(view);
view.querySelector('#accType').addEventListener('change', (e) => { state.type = e.target.value; state.page = 1; load(); });
view.querySelector('#accSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
view.querySelector('#accTrack').addEventListener('change', (e) => { state.track = e.target.value; state.page = 1; load(); });
view.querySelector('#accReset').addEventListener('click', () => { state = { type: 'all', search: '', track: '', page: 1, pageSize: 20 }; load(); });
bindPager(view, (p) => { state.page = p; load(); });
view.querySelectorAll('[data-create]').forEach((b) => b.addEventListener('click', () => {
firePrompt(`请为达人「${b.dataset.create}」创作一条短视频脚本。请先进行账号设定分析,再生成完整脚本。`, '创作指令');
}));
}
await load();
}
/* ================= 账号详情 ================= */
async function renderAccountDetail(view, id) {
const accountId = Number(id);
let page = 1, tab = 'info';
const d = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=1&pageSize=20').catch((e) => ({ ok: false, error: e.message }));
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '账号不存在')}</div></div>`; bindBack(view); return; }
const acc = d.account;
let persona = null, analysis = null;
async function loadPersona() {
const r = await api('/api/dsh/account-persona?accountId=' + accountId);
persona = r.content || null;
}
async function loadAnalysis() {
const r = await api('/api/dsh/account-analysis?accountId=' + accountId);
analysis = r.content || null;
}
async function load() {
const dv = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=' + page + '&pageSize=20');
const infoGrid = (label, val, wide) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${esc(fmt(val))}</div></div>`;
const rows = (dv.items || []).map((v) => `<tr>
<td><a class="link" href="#/video/${v.id}" title="查看视频分析详情">${esc(v.aweme_id || '-')}</a></td>
<td class="cell-content">${v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(v.video_title || '-')}</a>` : esc(v.video_title || '-')}</td>
<td>${fmt(v.like_display || v.like_count)}</td>
<td>${fmt(v.comment_count)}</td>
<td>${fmt(v.share_count)}</td>
<td>${fmtDuration(v.duration)}</td>
<td>${fmt(v.publish_time)}</td>
<td>${(v.has_source || v.has_analysis) ? `<button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ name: acc.account_name, title: v.video_title, aweme: v.aweme_id }))}'>改写脚本</button>` : `<button class="btn btn-sm" data-analyze='${esc(JSON.stringify({ name: acc.account_name, title: v.video_title, aweme: v.aweme_id }))}'>解析</button>`}</td>
</tr>`).join('');
const tabBtns = [['info', '账号信息'], ['persona', '账号设定'], ['analysis', '数据分析']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
let panel = '';
if (tab === 'info') {
panel = `<div class="info-grid">
${infoGrid('抖音号', acc.douyin_id)}${infoGrid('赛道', acc.track)}${infoGrid('AI内容', acc.is_ai === '是' ? '是' : '否')}
${infoGrid('粉丝', acc.followers)}${infoGrid('获赞', acc.total_likes)}${infoGrid('作品数', acc.works_count)}
${infoGrid('地区', acc.location)}${infoGrid('年龄', acc.age)}${infoGrid('获取视频', dv.total)}
${infoGrid('简介', acc.bio, true)}${infoGrid('定位/内容', acc.content, true)}
</div>
<div class="table-wrap"><table class="table"><thead><tr><th>视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty">暂无视频数据</td></tr>'}</tbody></table></div>
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}`;
} else if (tab === 'persona') {
panel = persona ? `<div class="md-body">${renderMarkdown(persona)}</div>` : `<div class="empty">该账号暂无账号设定信息<button class="btn btn-primary" data-extract style="margin-top:12px">提炼账号设定</button></div>`;
} else {
panel = analysis ? `<div class="md-body">${renderMarkdown(analysis)}</div>` : `<div class="empty">该账号暂无数据分析</div>`;
}
view.innerHTML = `
<div class="page">
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(acc.account_name || '账号详情')}</span></div>
<div class="tabs">${tabBtns}</div>
${panel}
</div>`;
bindBack(view);
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', async () => {
tab = b.dataset.tab;
if (tab === 'persona' && persona === null) await loadPersona();
if (tab === 'analysis' && analysis === null) await loadAnalysis();
load();
}));
bindPager(view, (p) => { page = p; load(); });
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
const r = JSON.parse(b.dataset.rewrite);
firePrompt(`请为达人「${r.name}」改写一条短视频故事脚本。视频选题:《${r.title}》\n请基于账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`, '改写指令');
}));
view.querySelectorAll('[data-analyze]').forEach((b) => b.addEventListener('click', () => {
const r = JSON.parse(b.dataset.analyze);
firePrompt(`请帮我解析这个抖音视频并做拆解分析:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》`, '解析指令');
}));
const exBtn = view.querySelector('[data-extract]');
if (exBtn) exBtn.addEventListener('click', () => firePrompt(`请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`, '提炼指令'));
}
await load();
}
/* ================= 视频详情 ================= */
async function renderVideoDetail(view, id) {
const videoId = Number(id);
let tab = 'script';
const d = await api('/api/dsh/video-detail?videoId=' + videoId).catch((e) => ({ ok: false, error: e.message }));
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '视频不存在')}</div></div>`; bindBack(view); return; }
const v = d.video, analysis = d.analysis, parse = d.parse || {};
const infoGrid = (label, val, wide, raw) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${raw ? val : esc(fmt(val))}</div></div>`;
const analysisToMd = (obj) => Object.entries(obj || {}).map(([k, val]) => `## ${k}\n\n${val}`).join('\n\n---\n\n');
function load() {
const titleVal = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(v.video_title)}</a>` : esc(v.video_title);
const scriptMd = parse.content || null;
const deconstructMd = parse.analysis ? analysisToMd(parse.analysis) : null;
const analysisMd = analysis && analysis.content ? analysis.content : null;
const tabBtns = [['script', '视频脚本'], ['deconstruct', '视频拆解'], ['analysis', '视频分析']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
let body = '';
if (tab === 'script') body = scriptMd ? `<div class="md-body">${renderMarkdown(scriptMd)}</div>` : `<div class="empty">该视频暂无视频脚本(未做原视频解析)</div>`;
else if (tab === 'deconstruct') body = deconstructMd ? `<div class="md-body">${renderMarkdown(deconstructMd)}</div>` : `<div class="empty">该视频暂无视频拆解(MCP 结构化分析)</div>`;
else body = analysisMd ? `<div class="md-body">${renderMarkdown(analysisMd)}</div>` : `<div class="empty">该视频暂无视频分析</div>`;
view.innerHTML = `
<div class="page">
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(v.video_title || '视频详情')}</span></div>
<div class="info-card"><div class="info-grid">
${infoGrid('视频标题', titleVal, true, true)}${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}
${infoGrid('点赞', v.like_display || v.like_count)}${infoGrid('评论', v.comment_count)}${infoGrid('分享', v.share_count)}
${infoGrid('收藏', v.collect_count)}${infoGrid('播放', (v.play_count == null || Number(v.play_count) === 0) ? null : v.play_count)}${infoGrid('时长', fmtDuration(v.duration))}
${infoGrid('发布时间', v.publish_time)}${infoGrid('标签', v.tags, true)}
</div></div>
<div class="tabs">${tabBtns}</div>
${body}
</div>`;
bindBack(view);
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => { tab = b.dataset.tab; load(); }));
}
load();
}
/* ================= AI写脚本 ================= */
async function renderRewrites(view) {
let state = { search: '', parsed: '', page: 1, pageSize: 20 };
async function load() {
const d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.search) + '&parsed=' + state.parsed + '&page=' + state.page + '&pageSize=' + state.pageSize);
const rows = (d.items || []).map((v) => `<tr>
<td>${esc(v.account_name || '-')}</td>
<td class="cell-content"><a class="link strong" data-detail="${v.id}">${esc(v.video_title || '-')}</a></td>
<td>${fmt(v.like_display || v.like_count)}</td>
<td>${fmt(v.comment_count)}</td>
<td>${fmt(v.share_count)}</td>
<td>${fmtDuration(v.duration)}</td>
<td>${fmt(v.publish_time)}</td>
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
<td>${v.rewrite_count > 0 ? '✅' + v.rewrite_count : '—'}</td>
<td><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: v.video_title }))}'>改写</button></td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
<div class="page-toolbar">
${toolbarBack()}
<span class="page-title">AI写脚本</span>
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.search)}">
<select id="rwParsed" class="input-select">
<option value="" ${state.parsed === '' ? 'selected' : ''}>全部状态</option>
<option value="1" ${state.parsed === '1' ? 'selected' : ''}>已解析</option>
<option value="0" ${state.parsed === '0' ? 'selected' : ''}>未解析</option>
</select>
<span class="pager-info">每账号近期 30W+ 且评论 1W+ 取 10 条</span>
</div>
<div class="table-wrap"><table class="table">
<thead><tr><th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>已改写</th><th>操作</th></tr></thead>
<tbody>${rows || '<tr><td colspan="10" class="empty">暂无视频数据</td></tr>'}</tbody>
</table></div>
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
</div>`;
bindBack(view);
view.querySelector('#rwSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
view.querySelector('#rwParsed').addEventListener('change', (e) => { state.parsed = e.target.value; state.page = 1; load(); });
bindPager(view, (p) => { state.page = p; load(); });
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
const r = JSON.parse(b.dataset.rewrite);
firePrompt(`请为达人「${r.name}」基于视频《${r.title}》改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。`, '改写指令');
}));
}
async function openRewriteCompare(videoId) {
const d = await api('/api/dsh/video-script?videoId=' + videoId).catch(() => ({ ok: false }));
const detail = await api('/api/dsh/video-detail?videoId=' + videoId).catch(() => ({ ok: false }));
const title = (detail.video && detail.video.video_title) || '';
const source = d.source || '(暂无源脚本数据)';
const rewrites = d.rewrites || [];
let verIdx = 0;
const stb = await api('/api/dsh/storyboards?videoId=' + videoId).catch(() => ({ items: [] }));
const storyboards = stb.items || [];
let sbIdx = 0;
function renderSbBlocks(text) {
const blocks = String(text || '').split(/\n\s*---+\s*\n/).map((b) => b.trim()).filter(Boolean);
if (blocks.length <= 1) return renderMarkdown(text || '');
return blocks.map((b, bi) => {
const lines = b.split('\n'); const first = lines[0].trim();
const m = /^段\s*(\d+)\s*[((]\s*([\d.]+)\s*-\s*([\d.]+)\s*秒\s*[))]\s*[||]\s*(.+)$/.exec(first);
let head = m ? `<span class="sb-title">${esc(m[4].trim())}</span><span class="sb-meta">${esc(m[2] + ' - ' + m[3] + '秒')}</span>` : `<span class="sb-title">场${bi + 1}</span>`;
const content = m ? lines.slice(1).join('\n').trim() : b;
return `<div class="sb-block"><div class="sb-head">${head}</div><div class="sb-body md-body">${renderMarkdown(content)}</div></div>`;
}).join('');
}
function renderModal() {
const cur = rewrites[verIdx];
const visibleSb = storyboards.filter((s) => !cur || s.rewrite_id === cur.id);
const sb = visibleSb[Math.min(sbIdx, visibleSb.length - 1)];
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>源脚本 vs AI写脚本 — ${esc(title)}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="rw-split">
<div class="rw-pane"><div class="rw-pane-title">源脚本</div><div class="rw-content md-body">${renderMarkdown(source)}</div></div>
<div class="rw-pane"><div class="rw-pane-title">AI脚本 ${rewrites.length > 1 ? `(版本 ${verIdx + 1}/${rewrites.length})` : ''}</div>
${rewrites.length > 1 ? `<div class="rw-ver"><button class="btn btn-sm" data-vprev ${verIdx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-vnext ${verIdx >= rewrites.length - 1 ? 'disabled' : ''}>下一版 →</button></div>` : ''}
<div class="rw-content md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div>
</div>
</div>
${visibleSb.length ? `<div class="rw-sb"><div class="rw-pane-title">AI分镜提示词 ${visibleSb.length > 1 ? `(${Math.min(sbIdx, visibleSb.length - 1) + 1}/${visibleSb.length})` : ''}</div>
${visibleSb.length > 1 ? `<div class="rw-ver"><button class="btn btn-sm" data-sprev ${sbIdx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-snext ${sbIdx >= visibleSb.length - 1 ? 'disabled' : ''}>下一版 →</button></div>` : ''}
<div class="rw-content">${sb ? renderSbBlocks(sb.script_text) : '(暂无分镜提示词)'}</div></div>` : ''}
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
const q = (s) => mask.querySelector(s);
const vprev = q('[data-vprev]'); if (vprev) vprev.addEventListener('click', () => { if (verIdx > 0) { verIdx--; sbIdx = 0; mask.remove(); renderModal(); } });
const vnext = q('[data-vnext]'); if (vnext) vnext.addEventListener('click', () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = 0; mask.remove(); renderModal(); } });
const sprev = q('[data-sprev]'); if (sprev) sprev.addEventListener('click', () => { if (sbIdx > 0) { sbIdx--; mask.remove(); renderModal(); } });
const snext = q('[data-snext]'); if (snext) snext.addEventListener('click', () => { if (sbIdx < visibleSb.length - 1) { sbIdx++; mask.remove(); renderModal(); } });
}
renderModal();
}
await load();
}
/* ================= AI写作复盘 ================= */
async function renderReviews(view) {
const d = await api('/api/dsh/rewrites').catch((e) => ({ ok: false, error: e.message }));
const DIMS = ['设定承接', '框架节奏', '钩子设置', '叙事逻辑', '极致事件', '共鸣和共情', '其他', '总分'];
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '加载失败')}</div></div>`; bindBack(view); return; }
const items = (d.items || []).filter((x) => x.status === 'done' && x.script_len > 0);
// 补账号名
const nameMap = {};
const vids = [...new Set(items.map((x) => x.video_id).filter((v) => v > 0))];
for (const vid of vids) {
const vd = await api('/api/dsh/video-detail?videoId=' + vid).catch(() => ({ ok: false }));
if (vd.ok && vd.video) nameMap[vid] = vd.video.account_name || '';
}
const dimsOf = (row) => {
const pick = (rv) => { try { return { dims: JSON.parse(rv.dims_json || '{}'), total: Number(rv.total) }; } catch (e) { return { dims: {}, total: NaN }; } };
if (row.review_ai && row.review_ai.status === 'done') return pick(row.review_ai);
if (row.review_compare && row.review_compare.status === 'done') { try { const j = JSON.parse(row.review_compare.dims_json || '{}'); const t = JSON.parse(row.review_compare.total || '{}'); return { dims: j.ai || {}, total: Number(t.ai) }; } catch (e) { return { dims: {}, total: NaN }; } }
return { dims: {}, total: NaN };
};
const reviewOf = (row) => (row.review_ai && row.review_ai.status === 'done' ? row.review_ai : (row.review_compare && row.review_compare.status === 'done' ? row.review_compare : null));
const fmtScore = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : '-');
const GEN = { rewrite: '爆款视频创作', new: '热门账号创作', abc: 'ABC融合创作' };
const rows = items.map((row) => {
const sd = dimsOf(row);
const rv = reviewOf(row);
const dimsTd = DIMS.map((dim) => {
const v = dim === '总分' ? sd.total : sd.dims[dim];
const clickable = dim === '总分' && rv && typeof v === 'number';
return `<td style="text-align:center;${dim === '总分' && typeof v === 'number' ? 'color:var(--primary);font-weight:600' : 'color:var(--dim)'}">${clickable ? `<a class="link" data-review="${rv.id}">${fmtScore(v)}</a>` : fmtScore(v)}</td>`;
}).join('');
return `<tr>
<td>#${row.id}</td>
<td>${esc(nameMap[row.video_id] || '-')}</td>
<td>${esc(GEN[row.genMethod || 'rewrite'])}</td>
<td class="cell-content">${row.title ? `<a class="link" data-open="${row.id}">${esc(row.title)}</a>` : '-'}</td>
<td>${fmtTime(row.created_time)}</td>
${dimsTd}
<td><button class="btn btn-sm" data-review-row="${row.id}" ${row.review_running ? 'disabled' : ''}>${row.review_running ? '复盘运行中' : '复盘'}</button></td>
</tr>`;
}).join('');
view.innerHTML = `
<div class="page">
<div class="page-toolbar">${toolbarBack()}<span class="page-title">AI写作复盘</span><span class="pager-info">按生成时间倒序 · 共 ${items.length} 条</span></div>
<div class="table-wrap"><table class="table">
<thead><tr><th>ID</th><th>账号</th><th>生成方式</th><th>脚本标题</th><th>生成时间</th>${DIMS.map((h) => `<th style="text-align:center">${h}</th>`).join('')}<th>操作</th></tr></thead>
<tbody>${rows || '<tr><td colspan="13" class="empty">暂无 AI 写脚本(完成改写的脚本会出现在这里)</td></tr>'}</tbody>
</table></div>
</div>`;
bindBack(view);
view.querySelectorAll('[data-open]').forEach((a) => a.addEventListener('click', () => openScriptDetail(a.dataset.open)));
view.querySelectorAll('[data-review]').forEach((a) => a.addEventListener('click', () => openReviewDetail(a.dataset.review)));
view.querySelectorAll('[data-review-row]').forEach((b) => b.addEventListener('click', () => {
firePrompt(`请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。`, '复盘指令');
}));
async function openScriptDetail(rewriteId) {
// 打开源 vs AI 对比(复用视频脚本弹窗逻辑的精简版)
const row = items.find((x) => String(x.id) === String(rewriteId));
if (!row) return;
const d2 = await api('/api/dsh/video-script?videoId=' + row.video_id).catch(() => ({ ok: false }));
const rewrites = (d2.rewrites || []);
const idx = rewrites.findIndex((x) => x.id === row.id);
const cur = idx >= 0 ? rewrites[idx] : null;
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>源脚本 vs AI写脚本 — ${esc(row.title || '')}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="rw-split">
<div class="rw-pane"><div class="rw-pane-title">源脚本</div><div class="rw-content md-body">${renderMarkdown(d2.source || '(暂无源脚本数据)')}</div></div>
<div class="rw-pane"><div class="rw-pane-title">AI脚本</div><div class="rw-content md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div></div>
</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
}
async function openReviewDetail(reviewId) {
const d3 = await api('/api/dsh/review-detail?id=' + reviewId).catch(() => ({ ok: false }));
if (!d3.ok) { showToast('复盘详情加载失败'); return; }
const rv = d3.review;
const typeName = rv.type === 'source' ? '原视频脚本复盘' : rv.type === 'compare' ? '对比分析' : 'AI 脚本复盘';
let dimsObj = {}; try { dimsObj = JSON.parse(rv.dims_json || '{}'); } catch (e) {}
const DIMS_ALL = ['设定承接', '框架节奏', '钩子设置', '叙事逻辑', '极致事件', '共鸣和共情', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他'];
const scoreColor = (v) => (v >= 75 ? '#3a9e6d' : v >= 60 ? '#d99a2b' : '#d9534f');
const scoreBar = (dims, prefix) => DIMS_ALL.map((k) => { const v = dims[k]; const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? scoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? scoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
let body = '';
if (rv.type === 'compare') {
const aiD = dimsObj.ai || {}, srcD = dimsObj.source || {};
let t = {}; try { t = JSON.parse(rv.total || '{}'); } catch (e) {}
body = `<div class="score-dual"><div class="score-box"><div class="score-box-title">AI 总分 ${fmtScore(Number(t.ai))}</div>${scoreBar(aiD, 'c-ai-')}</div><div class="score-box"><div class="score-box-title">源脚本 总分 ${fmtScore(Number(t.source))}</div>${scoreBar(srcD, 'c-src-')}</div></div>`;
} else {
body = `<div class="score-box"><div class="score-box-title">总分 ${fmtScore(Number(rv.total))}</div>${scoreBar(dimsObj, 'd-')}</div>`;
}
let issues = []; try { issues = JSON.parse(rv.issues_json || '[]'); } catch (e) {}
if (issues.length) body += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map((it) => `<div class="issue-row"><span style="color:${it.severity === '高' ? '#d9534f' : it.severity === '中' ? '#d99a2b' : '#3a9e6d'};font-weight:600">${esc(it.severity || '低')}</span><span>${esc(it.issue || '')}</span>${it.suggestion ? `<span class="dim">→ ${esc(it.suggestion)}</span>` : ''}</div>`).join('')}</div>`;
if (rv.summary) body += `<div class="review-summary">综合评语:${esc(rv.summary)}</div>`;
if (rv.content) body += `<div class="md-body" style="border-top:1px solid var(--border);padding-top:10px">${renderMarkdown(rv.content)}</div>`;
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>${esc(typeName)}${rv.verdict ? ' · ' + esc(rv.verdict) : ''}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="rw-scroll">${body || '加载中…'}</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
}
}
window.DataPages = { renderRanking, renderAccounts, renderAccountDetail, renderVideoDetail, renderRewrites, renderReviews };
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCN工作台</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo" data-icon="film" data-size="20"></span>
</div>
<nav class="crumbs" id="crumbs"></nav>
<div class="top-actions">
<button id="btnHome" class="ghost" title="回到首页"><i data-icon="home"></i> 首页</button>
<button id="btnHelp" class="ghost" title="功能帮助与快速提问"><i data-icon="help"></i> 帮助</button>
<button id="btnSettings" class="ghost" title="设置项目地址"><i data-icon="settings"></i> 设置</button>
</div>
</header>
<main id="view">
<div class="view-empty">加载中…</div>
</main>
<!-- ========== 设置弹窗 ========== -->
<div id="settingsModal" class="modal-mask" hidden>
<div class="modal">
<h3><i data-icon="settings"></i> 项目地址设置</h3>
<p class="dim">默认读取桌面 <code>MCNSkill项目</code>。可添加其他项目地址(如 dsh 环境的 <code>D:\dshworkspace</code>)。</p>
<div id="rootList" class="root-list"></div>
<div class="root-add">
<input id="newRoot" type="text" placeholder="输入项目地址完整路径,如 D:\dshworkspace">
<button id="btnAddRoot" class="primary">添加</button>
</div>
<p id="rootMsg" class="root-msg"></p>
<div class="modal-actions">
<button id="btnCloseSettings" class="ghost">关闭</button>
</div>
</div>
</div>
<script src="app.js"></script>
<script src="data-pages.js"></script>
</body>
</html>
@@ -0,0 +1,336 @@
/* MCN工作台样式 - 亮色主题 */
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e3e6ea;
--text: #24292f;
--dim: #6b7280;
--primary: #2f6fed;
--primary-soft: #eaf1fe;
--accent: #e8590c;
--shadow: 0 1px 3px rgba(0,0,0,.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
/* ---- 顶栏 ---- */
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 10px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 8px; }
.logo { display: inline-flex; align-items: center; color: var(--primary); }
.title { font-size: 18px; font-weight: 700; }
.subtitle { font-size: 12px; color: var(--dim); }
.tabs { display: flex; gap: 4px; margin-left: 20px; }
.tab {
border: none; background: none; padding: 8px 16px;
font-size: 14px; color: var(--dim); cursor: pointer;
border-radius: 8px;
}
.tab:hover { background: var(--primary-soft); color: var(--primary); }
.tab.active { background: var(--primary); color: #fff; font-weight: 600; }
.top-actions { margin-left: auto; }
.ghost {
border: 1px solid var(--border); background: var(--panel);
color: var(--text); padding: 6px 14px; border-radius: 8px;
cursor: pointer; font-size: 13px;
display: inline-flex; align-items: center; gap: 6px;
}
.ghost:hover { border-color: var(--primary); color: var(--primary); }
.primary {
border: none; background: var(--primary); color: #fff;
padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
/* ---- 主体 ---- */
main { height: calc(100vh - 54px); }
.panel { display: none; height: 100%; overflow: auto; padding: 24px; }
.panel.active { display: block; }
/* ---- 技能导航 ---- */
.intro { max-width: 900px; margin: 0 auto 20px; color: var(--dim); }
.func-grid {
max-width: 900px; margin: 0 auto;
display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
}
.func-card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; padding: 16px 18px; box-shadow: var(--shadow);
}
.func-card h3 { font-size: 16px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.func-card .desc { color: var(--dim); font-size: 13px; margin-bottom: 10px; }
.func-card .qa { border-top: 1px dashed var(--border); padding-top: 8px; }
.qa-row { display: flex; gap: 8px; padding: 4px 0; font-size: 13px; }
.qa-row .q { color: var(--accent); white-space: nowrap; }
.qa-row .a { color: var(--dim); }
.qa-row.copyable { cursor: pointer; border-radius: 6px; padding: 4px 6px; margin: 0 -6px; transition: background .15s; }
.qa-row.copyable:hover { background: var(--primary-soft); }
.qa-row.copyable:hover .q::after { content: ' 📋'; font-size: 12px; opacity: .7; }
.modes {
max-width: 900px; margin: 28px auto 0;
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; padding: 16px 18px;
}
.modes h3 { font-size: 15px; margin-bottom: 10px; }
.mode-table { border-collapse: collapse; width: 100%; }
.mode-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 13px; }
.mode-table tr:last-child td { border-bottom: none; }
.mode-name { font-weight: 600; width: 140px; display: flex; align-items: center; gap: 6px; }
.mode-hint { margin-top: 8px; font-size: 12px; color: var(--dim); }
/* ---- 产出浏览 ---- */
.files-layout { display: flex; height: 100%; gap: 0; }
.sidebar {
width: 320px; min-width: 320px; background: var(--panel);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.sidebar-top { padding: 12px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
.sidebar-top select, .sidebar-top input {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
font-size: 13px; width: 100%; background: #fff; color: var(--text);
}
.file-tree { flex: 1; overflow: auto; padding: 8px; }
.tree-empty { color: var(--dim); padding: 12px; font-size: 13px; }
.tree-node { font-size: 13px; }
.tree-row {
display: flex; align-items: center; gap: 6px;
padding: 4px 8px; border-radius: 6px; cursor: pointer; user-select: none;
white-space: nowrap; overflow: hidden;
}
.tree-row:hover { background: var(--primary-soft); }
.tree-row.selected { background: var(--primary); color: #fff; }
.tree-row .caret { width: 14px; text-align: center; font-size: 10px; flex-shrink: 0; }
.tree-row .icon { flex-shrink: 0; }
.tree-row .fname { overflow: hidden; text-overflow: ellipsis; }
.tree-children { margin-left: 18px; border-left: 1px dashed var(--border); padding-left: 4px; }
.ext-md { color: #8250df; }
.ext-json { color: #cf222e; }
.ext-xlsx { color: #1a7f37; }
.ext-txt, .ext-csv { color: #57606a; }
.content-view { flex: 1; overflow: auto; background: var(--panel); }
.preview-empty {
height: 100%; display: flex; flex-direction: column; align-items: center;
justify-content: center; color: var(--dim); gap: 8px;
}
.preview-empty p { font-size: 15px; }
.preview-empty .dim { font-size: 12px; }
.preview { padding: 24px 32px; max-width: 900px; margin: 0 auto; }
.preview .file-head {
display: flex; align-items: center; gap: 10px;
border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 16px;
}
.preview .file-head .fpath { font-size: 12px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview .file-head .fmeta { font-size: 12px; color: var(--dim); white-space: nowrap; }
.btn-download {
border: 1px solid var(--primary); color: var(--primary); background: #fff;
padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;
margin-left: auto; text-decoration: none;
}
.btn-download:hover { background: var(--primary-soft); }
/* ---- Markdown 渲染 ---- */
.md-body { line-height: 1.75; font-size: 14px; word-break: break-word; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4 { margin: 18px 0 10px; line-height: 1.4; }
.md-body h1 { font-size: 22px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
.md-body h2 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.md-body h3 { font-size: 16px; }
.md-body p { margin: 8px 0; }
.md-body strong { font-weight: 700; }
.md-body code {
background: #f0f2f5; border-radius: 4px; padding: 2px 5px;
font-family: Consolas, monospace; font-size: 13px;
}
.md-body pre {
background: #f6f8fa; border: 1px solid var(--border); border-radius: 8px;
padding: 12px 14px; overflow: auto; margin: 10px 0;
}
.md-body pre code { background: none; padding: 0; }
.md-body blockquote {
border-left: 4px solid var(--primary); background: var(--primary-soft);
padding: 8px 14px; margin: 10px 0; border-radius: 0 8px 8px 0; color: #333;
}
.md-body table { border-collapse: collapse; margin: 10px 0; width: 100%; display: block; overflow-x: auto; }
.md-body th, .md-body td { border: 1px solid var(--border); padding: 6px 10px; font-size: 13px; }
.md-body th { background: #f6f8fa; font-weight: 600; }
.md-body ul, .md-body ol { margin: 8px 0 8px 22px; }
.md-body li { margin: 3px 0; }
.md-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
.md-body a { color: var(--primary); }
/* ---- JSON 预览 ---- */
.json-view pre {
background: #f6f8fa; border: 1px solid var(--border); border-radius: 8px;
padding: 14px; overflow: auto; font-size: 13px; line-height: 1.6;
font-family: Consolas, monospace;
}
/* ---- 弹窗 ---- */
.modal-mask {
position: fixed; inset: 0; background: rgba(0,0,0,.35);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-mask[hidden] { display: none; } /* 修复:display:flex 会覆盖 hidden 属性,需显式复位 */
.modal {
background: #fff; border-radius: 14px; padding: 22px 26px;
width: 520px; max-width: 92vw; box-shadow: 0 8px 30px rgba(0,0,0,.18);
}
.modal h3 { margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.modal .dim { color: var(--dim); font-size: 13px; margin-bottom: 12px; }
.modal code { background: #f0f2f5; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.root-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.root-item {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; font-size: 13px;
}
.root-item .rp { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.root-item .rm {
border: none; background: none; color: #cf222e; cursor: pointer; font-size: 16px; line-height: 1;
}
.root-item .rm:hover { opacity: .7; }
.root-add { display: flex; gap: 8px; }
.root-add input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; }
.root-msg { margin-top: 8px; font-size: 12px; color: var(--accent); min-height: 16px; }
.modal-actions { text-align: right; margin-top: 14px; }
.dim { color: var(--dim); }
/* ---- Toast 提示 ---- */
.toast {
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(20px);
background: #24292f; color: #fff; padding: 10px 18px; border-radius: 8px;
font-size: 13px; opacity: 0; pointer-events: none; transition: all .25s;
max-width: 70vw; z-index: 200; box-shadow: 0 4px 16px rgba(0,0,0,.2);
word-break: break-all;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ================= 面包屑 ================= */
.crumbs { display: flex; align-items: baseline; gap: 8px; margin-left: 12px; min-width: 0; }
.crumb-home { font-size: 18px; font-weight: 700; color: var(--text); cursor: pointer; text-decoration: none; }
a.crumb-home:hover { color: var(--primary); }
.crumb-sep { color: var(--dim); font-size: 14px; }
.crumb-current { color: var(--text); font-weight: 600; font-size: 14px; }
main#view { height: calc(100vh - 54px); overflow: auto; }
.view-empty { padding: 40px; text-align: center; color: var(--dim); }
/* ================= 首页 ================= */
.home { max-width: 960px; margin: 0 auto; padding: 24px; }
.data-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin: 18px 0 24px; }
.data-card { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; cursor: pointer; text-align: left; box-shadow: var(--shadow); transition: all .15s; }
.data-card:hover { border-color: var(--primary); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(47,111,237,.15); }
.data-card-icon { color: var(--primary); display: inline-flex; }
.data-card-title { font-size: 15px; font-weight: 600; color: var(--text); }
.data-card-desc { font-size: 12px; color: var(--dim); }
/* ================= 功能页通用 ================= */
.page { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.page-title { font-size: 16px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
.input { border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 13px; min-width: 200px; background: #fff; color: var(--text); }
.input-select { border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; font-size: 13px; background: #fff; color: var(--text); }
.btn { border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; }
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-sm { padding: 3px 10px; font-size: 12px; border-radius: 6px; }
.btn-primary { background: var(--primary); color: #fff; border-color: transparent; }
.btn-primary:hover { background: #2a5fd0; color: #fff; }
.empty { padding: 30px; text-align: center; color: var(--dim); font-size: 13px; }
/* ================= 表格 ================= */
.table-wrap { overflow: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { position: sticky; top: 0; background: #f6f8fa; text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-weight: 600; white-space: nowrap; z-index: 1; }
.table td { padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.table tbody tr:hover { background: var(--primary-soft); }
.cell-content { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sortable { cursor: pointer; }
.sortable:hover { color: var(--primary); }
.link { color: var(--primary); cursor: pointer; text-decoration: none; }
.link:hover { text-decoration: underline; }
.link.strong { font-weight: 600; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; }
.badge-signed { background: var(--primary-soft); color: var(--primary); }
.badge-ext { background: #fff4e5; color: var(--accent); }
.pager { display: flex; align-items: center; gap: 12px; padding: 12px 4px; }
.pager-total, .pager-info { color: var(--dim); font-size: 13px; }
/* ================= 数据页 tab ================= */
.tabs { display: flex; gap: 4px; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
.tab { border: none; background: none; padding: 8px 16px; font-size: 14px; color: var(--dim); cursor: pointer; border-bottom: 2px solid transparent; }
.tab:hover { color: var(--primary); }
.tab-active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
/* ================= 信息卡 ================= */
.info-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; background: var(--panel); }
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px 18px; }
.info-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.info-wide { grid-column: 1/-1; }
.info-label { font-size: 12px; color: var(--dim); }
.info-value { font-size: 13px; color: var(--text); word-break: break-word; white-space: pre-wrap; line-height: 1.6; }
/* ================= 对比弹窗 ================= */
.modal-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; font-size: 16px; font-weight: 600; }
.rw-modal { width: min(1100px, 95vw); max-height: 90vh; overflow: auto; }
.rw-split { display: flex; gap: 14px; }
.rw-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rw-pane-title { font-weight: 600; margin-bottom: 8px; }
.rw-content { flex: 1; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; max-height: 60vh; }
.rw-ver { display: flex; gap: 6px; margin-bottom: 8px; }
.rw-sb { margin-top: 14px; }
.rw-scroll { padding: 4px 2px; max-height: 75vh; overflow: auto; }
.sb-block { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; background: var(--panel); }
.sb-head { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: #f6f8fa; border-bottom: 1px solid var(--border); }
.sb-title { font-weight: 600; color: var(--primary); }
.sb-meta { margin-left: auto; font-size: 12px; color: var(--dim); }
.sb-body { padding: 8px 12px; }
/* ================= 评分 ================= */
.score-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.score-name { width: 88px; font-size: 12px; color: var(--dim); white-space: nowrap; flex-shrink: 0; }
.score-bar { flex: 1; height: 6px; border-radius: 3px; background: #eef1f5; overflow: hidden; }
.score-fill { height: 100%; }
.score-val { width: 36px; text-align: right; font-size: 12px; font-weight: 600; flex-shrink: 0; }
.score-dual { display: flex; gap: 12px; margin-bottom: 12px; }
.score-box { flex: 1; padding: 10px; border: 1px solid var(--border); border-radius: 8px; }
.score-box-title { font-weight: 600; margin-bottom: 8px; }
.review-issues { margin-bottom: 10px; }
.issue-row { display: flex; gap: 6px; font-size: 12px; margin-bottom: 4px; }
.review-summary { margin-bottom: 10px; font-size: 13px; }
/* ================= 首页 AI写剧本 快捷卡片 ================= */
.recent-scripts { margin: 4px 0 24px; }
.recent-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.recent-head h3 { font-size: 15px; }
.recent-head .link { font-size: 13px; }
.recent-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; }
.recent-card { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; cursor: pointer; text-align: left; transition: all .15s; min-height: 172px; }
.recent-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(47,111,237,.12); }
.recent-card-title { font-size: 13px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; display: flex; align-items: center; gap: 6px; }
.recent-card-preview { width: 100%; display: flex; flex-direction: column; gap: 2px; }
.recent-preview-line { display: block; font-size: 12px; color: #57606a; line-height: 1.55; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; }
.recent-card-meta { font-size: 12px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; margin-top: auto; }
.recent-meta { font-size: 12px; color: var(--dim); margin-bottom: 12px; }
/* ================= 扁平化向量图标 ================= */
.ic { display: inline-block; vertical-align: -0.18em; flex-shrink: 0; }
.top-actions .ghost .ic { color: var(--dim); }
.top-actions .ghost:hover .ic { color: var(--primary); }
@@ -0,0 +1,321 @@
// MCN工作台 - 本地 Web 服务(零依赖 Node 实现)
// 启动: node server.js → http://localhost:8899
// 功能: 技能导航 + 产出内容浏览(默认读桌面 MCNSkill项目)+ dsh 数据功能页(账号/视频/脚本/复盘/周榜,只读)
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { URL } = require('url');
const dsh = require('./dsh-data');
const PORT_BASE = 8899;
const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public');
const CONFIG_FILE = path.join(ROOT, 'config.json');
// ---- 默认根目录:桌面/MCNSkill项目 ----
const DEFAULT_ROOT = path.join(os.homedir(), 'Desktop', 'MCNSkill项目');
// ---- 配置读写(自定义根目录列表)----
function loadConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) {
const c = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
if (Array.isArray(c.extraRoots)) return { extraRoots: c.extraRoots };
}
} catch (e) { /* 忽略损坏配置 */ }
return { extraRoots: [] };
}
function saveConfig(cfg) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8');
}
// ---- 允许读取的根目录白名单 ----
function allowedRoots() {
const cfg = loadConfig();
const list = [DEFAULT_ROOT].concat(cfg.extraRoots || []);
// 去重、只保留存在的目录
return list.filter((p, i) => p && fs.existsSync(p) && fs.statSync(p).isDirectory() && list.indexOf(p) === i);
}
// ---- 路径安全:必须落在某个允许根目录内 ----
function resolveSafe(rawPath) {
if (!rawPath) return null;
let p;
try { p = path.resolve(rawPath); } catch (e) { return null; }
for (const root of allowedRoots()) {
const r = path.resolve(root);
if (p === r || p.startsWith(r + path.sep)) {
return { abs: p, root: r };
}
}
return null;
}
// ---- 目录树(递归,跳过隐藏项)----
function buildTree(absPath, depth) {
if (depth > 4) return null;
let entries;
try { entries = fs.readdirSync(absPath, { withFileTypes: true }); } catch (e) { return null; }
const dirs = [];
const files = [];
for (const ent of entries) {
if (ent.name.startsWith('.')) continue; // 隐藏文件
if (ent.name === 'node_modules' || ent.name === '.git') continue;
const full = path.join(absPath, ent.name);
try {
if (ent.isDirectory()) {
const child = buildTree(full, depth + 1);
if (child) dirs.push({ name: ent.name, path: full, type: 'dir', children: child });
} else if (ent.isFile()) {
const st = fs.statSync(full);
files.push({ name: ent.name, path: full, type: 'file', ext: path.extname(ent.name).toLowerCase(), size: st.size });
}
} catch (e) { /* 权限/占用跳过 */ }
}
dirs.sort((a, b) => a.name.localeCompare(b.name, 'zh'));
files.sort((a, b) => a.name.localeCompare(b.name, 'zh'));
return dirs.concat(files);
}
// ---- MIME ----
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
function mimeOf(p) {
return MIME[path.extname(p).toLowerCase()] || 'application/octet-stream';
}
// ---- 读取文本文件(按扩展名判定预览能力)----
const PREVIEW_EXTS = ['.md', '.markdown', '.txt', '.json', '.csv', '.log'];
function readText(absPath) {
const ext = path.extname(absPath).toLowerCase();
if (!PREVIEW_EXTS.includes(ext)) return null;
const buf = fs.readFileSync(absPath);
// BOM 去除
let text = buf.toString('utf8');
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
return text;
}
// ---- 简易日志 ----
function log(msg) {
console.log('[' + new Date().toLocaleTimeString('zh-CN', { hour12: false }) + '] ' + msg);
}
// ---- 静态文件 / API ----
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://localhost');
const p = decodeURIComponent(u.pathname);
const q = u.searchParams;
// 统一 JSON 响应
const sendJSON = (code, obj) => {
const body = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(body);
};
const sendErr = (code, msg) => sendJSON(code, { error: msg });
// ---------- API ----------
if (p === '/api/roots') {
return sendJSON(200, {
defaultRoot: DEFAULT_ROOT,
extraRoots: loadConfig().extraRoots,
defaultExists: fs.existsSync(DEFAULT_ROOT),
});
}
if (p === '/api/roots' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
req.on('end', () => {
try {
const { extraRoots } = JSON.parse(body || '{}');
if (!Array.isArray(extraRoots)) return sendErr(400, 'extraRoots 必须为数组');
const cleaned = extraRoots
.map((x) => (typeof x === 'string' ? x.trim() : ''))
.filter((x) => x && fs.existsSync(x) && fs.statSync(x).isDirectory());
saveConfig({ extraRoots: cleaned });
sendJSON(200, { extraRoots: cleaned });
} catch (e) {
sendErr(500, '配置保存失败: ' + e.message);
}
});
return;
}
if (p === '/api/tree') {
const rootParam = q.get('root') || DEFAULT_ROOT;
const safe = resolveSafe(rootParam);
if (!safe) return sendErr(403, '目录不在允许范围,请先在设置中添加');
const children = buildTree(safe.abs, 0) || [];
return sendJSON(200, { root: safe.abs, tree: children });
}
if (p === '/api/read') {
const safe = resolveSafe(q.get('path'));
if (!safe) return sendErr(403, '路径不在允许范围');
try {
const st = fs.statSync(safe.abs);
if (st.size > 5 * 1024 * 1024) return sendErr(413, '文件超过 5MB,不支持在线预览,请下载查看');
const text = readText(safe.abs);
if (text === null) return sendErr(415, '该类型不支持在线预览,请下载后用本地软件打开');
return sendJSON(200, {
name: path.basename(safe.abs),
path: safe.abs,
ext: path.extname(safe.abs).toLowerCase(),
size: st.size,
content: text,
});
} catch (e) {
return sendErr(500, '读取失败: ' + e.message);
}
}
if (p === '/api/download') {
const safe = resolveSafe(q.get('path'));
if (!safe) return sendErr(403, '路径不在允许范围');
try {
const st = fs.statSync(safe.abs);
const fname = encodeURIComponent(path.basename(safe.abs));
res.writeHead(200, {
'Content-Type': mimeOf(safe.abs),
'Content-Length': st.size,
'Content-Disposition': "attachment; filename*=UTF-8''" + fname,
'Cache-Control': 'no-store',
});
fs.createReadStream(safe.abs).pipe(res);
} catch (e) {
return sendErr(500, '下载失败: ' + e.message);
}
return;
}
// ---------- 数据功能(只读工作台自有数据库副本) ----------
const dshOk = (code, obj) => sendJSON(code, { ok: true, ...obj });
const dshGuard = (res2) => { if (!dsh.dbAvailable()) { sendJSON(200, { ok: false, error: '未检测到工作台数据库(mcn-plugin.db),本功能不可用' }); return true; } return false; };
const qInt = (k, def) => { const v = Number(q.get(k)); return Number.isFinite(v) ? v : def; };
if (p === '/api/dsh/meta') {
return sendJSON(200, { ok: true, db: dsh.dbAvailable(), dbPath: dsh.DSH_DB, rankingDir: dsh.RANKING_DIR, ranking: dsh.rankingMeta() });
}
if (p === '/api/dsh/stats') {
if (dshGuard()) return;
return dshOk(200, { stats: dsh.getStats() });
}
if (p === '/api/dsh/recent-scripts') {
if (dshGuard()) return;
return dshOk(200, { items: dsh.listRecentScripts(qInt('limit', 4)) });
}
if (p === '/api/dsh/tracks') {
if (dshGuard()) return;
return dshOk(200, { tracks: dsh.listTracks() });
}
if (p === '/api/dsh/accounts') {
if (dshGuard()) return;
try {
return dshOk(200, dsh.listAccounts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), track: String(q.get('track') || ''), type: String(q.get('type') || 'all') }));
} catch (e) { return sendErr(500, e.message); }
}
if (p === '/api/dsh/account-videos') {
if (dshGuard()) return;
try {
return dshOk(200, dsh.listAccountVideos(qInt('accountId', 0), qInt('page', 1), qInt('pageSize', 20)));
} catch (e) { return sendErr(500, e.message); }
}
if (p === '/api/dsh/account-persona') {
if (dshGuard()) return;
const r = dsh.getPersona(qInt('accountId', 0));
return dshOk(200, { content: r ? r.content : null, analysisTime: r ? r.analysis_time : null });
}
if (p === '/api/dsh/account-analysis') {
if (dshGuard()) return;
const r = dsh.getAccountAnalysis(qInt('accountId', 0));
return dshOk(200, { content: r ? r.content : null, analysisTime: r ? r.analysis_time : null });
}
if (p === '/api/dsh/videos') {
if (dshGuard()) return;
return dshOk(200, dsh.listVideos({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), parsed: String(q.get('parsed') || '') }));
}
if (p === '/api/dsh/video-detail') {
if (dshGuard()) return;
try {
return dshOk(200, dsh.getVideoDetail({ videoId: qInt('videoId', 0), awemeId: q.get('awemeId') }));
} catch (e) { return sendErr(500, e.message); }
}
if (p === '/api/dsh/video-script') {
if (dshGuard()) return;
try {
return dshOk(200, dsh.getVideoScript(qInt('videoId', 0)));
} catch (e) { return sendErr(500, e.message); }
}
if (p === '/api/dsh/rewrites') {
if (dshGuard()) return;
return dshOk(200, dsh.listRewrites(qInt('videoId', 0)));
}
if (p === '/api/dsh/storyboards') {
if (dshGuard()) return;
return dshOk(200, { items: dsh.listStoryboards(qInt('videoId', 0)) });
}
if (p === '/api/dsh/review-detail') {
if (dshGuard()) return;
const r = dsh.getReviewDetail(qInt('id', 0));
return r ? dshOk(200, { review: r }) : sendErr(404, '复盘记录不存在');
}
if (p === '/api/dsh/review-full') {
if (dshGuard()) return;
const r = dsh.getReviewFull(qInt('rewriteId', 0));
return r ? dshOk(200, { reviews: r }) : sendErr(404, '改写记录不存在');
}
if (p === '/api/dsh/ranking') {
try {
const r = dsh.getRanking({ date: String(q.get('date') || ''), category: String(q.get('category') || ''), sortBy: String(q.get('sortBy') || 'rank'), order: String(q.get('order') || ''), page: qInt('page', 1), pageSize: qInt('pageSize', 20) });
return dshOk(200, r);
} catch (e) { return sendErr(500, e.message); }
}
// ---------- 静态文件 ----------
let filePath = path.join(PUBLIC_DIR, p === '/' ? 'index.html' : p);
// 防穿越
if (!filePath.startsWith(PUBLIC_DIR)) return sendErr(403, '禁止访问');
fs.stat(filePath, (err, st) => {
if (err || !st.isFile()) return sendErr(404, 'Not Found');
res.writeHead(200, { 'Content-Type': mimeOf(filePath), 'Content-Length': st.size, 'Cache-Control': 'no-store' });
fs.createReadStream(filePath).pipe(res);
});
});
// ---- 端口自动避让 ----
function listen(port) {
server.once('error', (e) => {
if (e.code === 'EADDRINUSE') {
log('端口 ' + port + ' 被占用,尝试 ' + (port + 1));
listen(port + 1);
} else {
console.error('启动失败:', e.message);
process.exit(1);
}
});
server.listen(port, '127.0.0.1', () => {
log('MCN工作台已启动: http://localhost:' + port);
log('默认项目地址: ' + DEFAULT_ROOT + (fs.existsSync(DEFAULT_ROOT) ? '' : '(不存在)'));
log('按 Ctrl+C 停止服务');
});
}
listen(PORT_BASE);
@@ -0,0 +1,26 @@
@echo off
rem MCN工作台 启动脚本(Windows 双击运行)
rem 优先使用 WorkBuddy managed node,回退系统 node
setlocal
cd /d "%~dp0"
set "NODE_EXE=C:\Users\maidou\.workbuddy\binaries\node\versions\22.22.2\node.exe"
if exist "%NODE_EXE%" goto run
where node >nul 2>nul
if %errorlevel%==0 (
set "NODE_EXE=node"
goto run
)
echo [错误] 未找到 Node.js,请安装 Node 或配置 managed node 路径
pause
exit /b 1
:run
echo 正在启动 MCN工作台...
start "" "%NODE_EXE%" server.js
timeout /t 1 /nobreak >nul
start http://localhost:8899
endlocal