- 账号列表页新增「导入账号」:两种方式(抖音账号信息/本地文件夹),复用 executeTask 任务链路提交 AI 任务 - 查看改写弹窗:展开源脚本按钮移入 AI脚本 标题右侧(间隔10px),弹窗最大宽度 1400→1600 - 短视频提示词生成 整体移入 短视频脚本创作/V1.0/subskill/mcn-video-prompt(与 mcn-dou-analysis 同构,138 文件 R100 保留历史) - 工作台 dsh-data/server 若干修复 + 记忆更新
460 lines
20 KiB
JavaScript
460 lines
20 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-favorite' && req.method === 'POST') {
|
||
if (dshGuard()) return;
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||
req.on('end', () => {
|
||
try {
|
||
const { id, fav } = JSON.parse(body || '{}');
|
||
if (!id) return sendErr(400, '缺少 id');
|
||
dsh.setFavorite(id, !!fav);
|
||
return dshOk(200, { id, favorite: !!fav });
|
||
} catch (e) { return sendErr(500, e.message); }
|
||
});
|
||
return;
|
||
}
|
||
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); }
|
||
}
|
||
|
||
// ---------- 提交 AI 任务(Automation 一次性任务,WorkBuddy 自动执行,左侧会话栏可见)----------
|
||
if (p === '/api/run' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||
req.on('end', () => {
|
||
try {
|
||
const { prompt, name } = JSON.parse(body || '{}');
|
||
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) return sendErr(400, 'prompt 不能为空');
|
||
const { DatabaseSync } = require('node:sqlite');
|
||
const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'));
|
||
const now = Date.now();
|
||
const id = 'automation-' + now;
|
||
const cwd = 'D:\\AgentSkill\\mcn-workshop'; // 工作台触发的会话归入 mcn-workshop 空间分组
|
||
const d = new Date();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
const uidRow = db.prepare("SELECT user_id FROM sessions WHERE user_id IS NOT NULL AND user_id <> '' ORDER BY created_at DESC LIMIT 1").get();
|
||
const ownerUserId = uidRow ? uidRow.user_id : '';
|
||
db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,next_run_at,scheduled_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||
.run(id, name || '工作台任务', prompt.trim(), 'ACTIVE', 'once', now, scheduledAt, '', JSON.stringify([cwd]), now, now, '[]', '[]', 'deepseek-v4-flash', 'fullAccess', ownerUserId, 'confirmed');
|
||
db.close();
|
||
log('已提交 AI 任务: ' + id + (name ? ' (' + name + ')' : ''));
|
||
return sendJSON(200, { ok: true, id, message: '任务已提交,请在左侧会话栏查看执行' });
|
||
} catch (e) {
|
||
return sendErr(500, '任务提交失败: ' + e.message);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 查询 AI 任务执行状态(供前端轮询恢复按钮):queued / pending / running / done / error / notfound
|
||
if (p === '/api/run/status' && req.method === 'GET') {
|
||
const id = q.get('id');
|
||
if (!id) return sendErr(400, '缺少 id');
|
||
try {
|
||
const { DatabaseSync } = require('node:sqlite');
|
||
const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'), { readOnly: true });
|
||
const auto = db.prepare('SELECT status, deleted_at, last_run_at FROM automations WHERE id=?').get(id);
|
||
if (!auto || auto.deleted_at) { db.close(); return sendJSON(200, { ok: true, id, state: 'notfound' }); }
|
||
const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(id);
|
||
const runs = db.prepare("SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1").get(id);
|
||
db.close();
|
||
let state = 'queued';
|
||
if (st && Number(st.running) === 1) state = 'running';
|
||
else if (runs) {
|
||
const s = String(runs.status || '').toUpperCase();
|
||
if (s === 'IN_PROGRESS') state = 'running';
|
||
else if (s === 'PENDING_REVIEW') state = 'pending';
|
||
else if (['DONE', 'COMPLETED', 'SUCCESS', 'FINISHED'].includes(s)) state = 'done';
|
||
else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error';
|
||
else state = 'pending';
|
||
}
|
||
return sendJSON(200, { ok: true, id, state, error: (st && st.last_error) || null });
|
||
} catch (e) {
|
||
return sendErr(500, '状态查询失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ---------- AI 生成选题(Dify chat-messages 即时调用,参考技能 S5 选题方法论) ----------
|
||
if (p === '/api/ai/topics' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||
req.on('end', async () => {
|
||
try {
|
||
const { accountName } = JSON.parse(body || '{}');
|
||
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
|
||
// 1) 查账号定位 + 人设摘要(工作台库)
|
||
let contentText = '', personaText = '';
|
||
try {
|
||
const { DatabaseSync } = require('node:sqlite');
|
||
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true });
|
||
const acc = db.prepare('SELECT * FROM hot_accounts WHERE account_name=? LIMIT 1').get(accountName);
|
||
if (acc) {
|
||
contentText = String(acc.content || '').slice(0, 500);
|
||
const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id);
|
||
if (p) personaText = String(p.summary || p.content_json || '').slice(0, 500);
|
||
}
|
||
db.close();
|
||
} catch (e) { /* 库不可用不阻塞,仍可生成 */ }
|
||
// 2) 读 Dify key:环境变量优先,其次技能脚本内置默认
|
||
let apiKey = process.env.DIFY_MCN_CYLG_KEY || '';
|
||
if (!apiKey) {
|
||
const scriptPath = path.join(ROOT, '..', 'scripts', 'MCN_CYLG_API.py');
|
||
try {
|
||
const src = fs.readFileSync(scriptPath, 'utf8');
|
||
const m = src.match(/DEFAULT_CYLG_KEY\s*=\s*"([^"]+)"/);
|
||
if (m) apiKey = m[1];
|
||
} catch (e) { /* 脚本不存在则走 env */ }
|
||
}
|
||
if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)');
|
||
// 3) 构造选题 query(参考 references/创作流程/5_生成短视频选题.md 方法论)
|
||
const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定生成 3 个爆款选题方案。\n\n'
|
||
+ '【达人信息】\n' + (contentText || '(无账号定位信息)') + '\n'
|
||
+ (personaText ? '【人设摘要】\n' + personaText + '\n' : '')
|
||
+ '\n要求:\n'
|
||
+ '1. 选题要跳出账号核心行为框架,但保留账号结构性符号(固定机制/口头禅等)\n'
|
||
+ '2. 每个选题一句话说清主题+切入角度,标注目标体感(如治愈感/爽感/获得感)\n'
|
||
+ '3. 只输出 3 个选题,每行一个,格式:1. 主题(切入角度|目标体感)\n'
|
||
+ '4. 不要输出其他解释';
|
||
// 4) 调 Dify chat-messages(blocking 即时返回)
|
||
const difyUrl = String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1').replace(/\/+$/, '');
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 100000);
|
||
let resp;
|
||
try {
|
||
resp = await fetch(difyUrl + '/chat-messages', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }),
|
||
signal: ctrl.signal,
|
||
});
|
||
} finally { clearTimeout(timer); }
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) return sendErr(502, 'AI 接口错误: ' + (data.message || resp.status));
|
||
log('AI 选题生成: ' + accountName);
|
||
return dshOk(200, { topics: data.answer || '' });
|
||
} catch (e) {
|
||
return sendErr(500, '选题生成失败: ' + e.message);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ---------- 静态文件 ----------
|
||
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);
|