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:
1 parent
f025ad3b86
commit
5f5b4f1cb7
120 files changed
+44528
-1
No files matched your search
@@ -9,7 +9,7 @@ description: >
|
||||
诊断脚本质量、设计账号人设、选题策划、生成分镜、广告植入设计、生成创作回顾。
|
||||
version: "1.0"
|
||||
updated_at: "2026-08-26"
|
||||
last_change: "帮助文档功能重组:按创作链路改为5大功能块(找选题灵感→写脚本→账号分析[提炼设定/分析视频/分析账号]→诊断评分→工具接口),原「从账号分析到脚本创作」衔接场景并入写脚本块;前置变更:开场口径区分固化+输出边界铁律固化+产出层级精简"
|
||||
last_change: "MCN工作台大版本升级:hash 路由 + 数据功能页(抖音周榜/热门账号/账号详情/视频详情/AI写脚本/AI写作复盘,只读复用 dsh 库 mcn-plugin.db + 抖音榜单 JSON,dsh-data.js 数据层);所有触发AI按钮降级为点一下复制 prompt;前端拆 app.js+data-pages.js"
|
||||
deployment: "source"
|
||||
agent_created: true
|
||||
---
|
||||
@@ -53,6 +53,26 @@ agent_created: true
|
||||
|
||||
---
|
||||
|
||||
|
||||
## MCN工作台(本地 Web 入口,仅 V1.0 源技能环境)
|
||||
|
||||
用户说「打开工作台 / 打开MCN工作台」时:
|
||||
|
||||
1. 后台启动本地服务:`node <本技能目录>/mcn-work-shop/server.js`(零依赖 Node,无需安装包;Windows 也可双击 `start.bat`)
|
||||
2. 用内置浏览器打开 `http://localhost:8899`(端口被占自动 +1,以启动日志为准)
|
||||
3. 工作台采用 hash 路由,页面结构:
|
||||
- **首页(#/)**:数据功能卡片(抖音周榜/热门账号/AI写脚本/AI写作复盘)+ 5 大技能功能块(问法点击复制)+ 三种创作模式
|
||||
- **📈 抖音周榜(#/ranking)**:读 `D:\dshworkspace\抖音榜单\*.json`(环境变量 `MCN_RANKING_DIR` 可覆盖),赛道筛选 + 日期切换 + 数值排序
|
||||
- **👥 热门账号(#/accounts)**:读工作台自有数据库副本 `mcn-work-shop/mcn-plugin.db`(只读),分页/搜索/赛道/类型筛选,账号点击进详情
|
||||
- **账号详情(#/account/:id)**:信息卡 + 账号信息/账号设定/数据分析 3 tab + 视频列表分页;解析/改写/提炼按钮降级为「点一下复制 prompt」
|
||||
- **视频详情(#/video/:id)**:信息卡 + 视频脚本/视频拆解/视频分析 3 tab
|
||||
- **✍️ AI写脚本(#/rewrites)**:爆款视频列表 + 源脚本 vs AI脚本双栏对比 + 分镜提示词分块渲染
|
||||
- **🧪 AI写作复盘(#/reviews)**:评分维度列表 + 复盘详情(评分条/问题清单/报告)
|
||||
- **📁 产出浏览(#/files)**:默认读桌面 `MCNSkill项目/`,支持 ⚙️ 设置添加其他项目地址;md/json 在线预览
|
||||
4. **数据边界**:数据库读工作台自有副本 `mcn-work-shop/mcn-plugin.db`(由 dsh 库复制而来,与 dsh 原库 `~/.dsh/mcn-plugin.db` 完全隔离,两边各访问各的互不影响);周榜读 `D:\dshworkspace\抖音榜单\*.json` 文件;所有「触发 AI」按钮因 WorkBuddy 无 AI 会话桥接,统一降级为「点一下复制 prompt 到剪贴板」,粘贴到对话框执行
|
||||
5. 本功能仅 V1.0 源技能环境提供,**不同步 dsh/Lite 副本**
|
||||
|
||||
---
|
||||
## MCP 依赖(摘要)
|
||||
|
||||
本技能的 API 功能(创意灵感/视频解析)依赖 MCN MCP 服务(`myai-mcp-production` 正式 / `myai-mcp-test` 测试)。
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> 本技能提供短视频**创作、分析、诊断**的全链路能力。直接对话即可使用,无需记住指令。
|
||||
|
||||
> 💻 想看技能导航 + 浏览已生成内容(脚本/账号设定/视频分析)?说「**打开工作台**」——本地 Web 入口,默认读桌面 `MCNSkill项目/`,可在 ⚙️ 设置中添加其他产出目录。(仅本机 V1.0 环境提供)
|
||||
|
||||
---
|
||||
|
||||
## 💡 找选题 & 灵感
|
||||
|
||||
Reference in new issue
Block a user