- 工作台大版本升级: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)、弹窗关闭、外链新标签打开等
322 lines
12 KiB
JavaScript
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);
|