Files
mcn-short-video/project/短视频脚本创作/V1.0/mcn-work-shop/server.js
T
maogeigei d3f98dda77 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)、弹窗关闭、外链新标签打开等
2026-08-27 12:33:57 +08:00

322 lines
12 KiB
JavaScript

// 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);