Files
feishu_approval_design/server.js
2026-06-11 17:53:13 +08:00

207 lines
6.3 KiB
JavaScript

const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const ROOT = __dirname;
const CONFIG_PATH = path.join(ROOT, 'project.config.json');
const TEMPLATE_HTML = path.join(ROOT, '飞书审批配置文件_V1.0.html');
const ADDRESS_REGIONS_JS = path.join(ROOT, 'address-regions.js');
function loadProjectConfig() {
try {
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch (e) {}
return { projectId: 'approval_of_design', port: 8080, host: '0.0.0.0', label: '飞书审批配置服务' };
}
const PROJECT = loadProjectConfig();
const PORT = Number(process.env.PORT) || PROJECT.port || 8080;
const HOST = process.env.HOST || PROJECT.host || '0.0.0.0';
const NODE_MAJOR = parseInt(String(process.versions.node).split('.')[0], 10);
if (NODE_MAJOR < 16) {
console.error('[ERROR] Node.js 16.x or later is required. Current: ' + process.version);
process.exit(1);
}
let templateCache = null;
function readTemplate() {
if (!templateCache) {
templateCache = fs.readFileSync(TEMPLATE_HTML, 'utf8');
}
return templateCache;
}
function reloadTemplate() {
templateCache = null;
return readTemplate();
}
function safeJsonForScript(value) {
return JSON.stringify(value).replace(/<\//g, '\\u003c/');
}
function renderBootScript(config, mode) {
const project = {
id: PROJECT.projectId || 'approval_of_design',
label: PROJECT.label || '飞书审批配置服务'
};
return [
'<script id="app-boot">',
'window.APP_PROJECT = ' + safeJsonForScript(project) + ';',
"window.APP_MODE = '" + mode + "';",
'window.__INITIAL_CONFIG__ = ' + safeJsonForScript(config) + ';',
"document.documentElement.setAttribute('data-app-mode', window.APP_MODE);",
'</script>'
].join('\n');
}
function injectBoot(html, config, mode) {
return html.replace(/<script id="app-boot">[\s\S]*?<\/script>/, renderBootScript(config, mode));
}
function inlineAddressRegions(html) {
if (!fs.existsSync(ADDRESS_REGIONS_JS)) return html;
const js = fs.readFileSync(ADDRESS_REGIONS_JS, 'utf8');
return html.replace(
/<script src="address-regions\.js"><\/script>/,
'<script>\n' + js + '\n</script>'
);
}
function prepareHtml(config, mode) {
return inlineAddressRegions(injectBoot(readTemplate(), config, mode));
}
function sanitizeFilename(name) {
return String(name || '审批配置')
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 80) || '审批配置';
}
function sendJson(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
}
function readBody(req, limit) {
const maxBytes = limit || 20 * 1024 * 1024;
return new Promise(function (resolve, reject) {
var body = '';
var size = 0;
req.on('data', function (chunk) {
size += chunk.length;
if (size > maxBytes) {
reject(new Error('request body too large'));
req.destroy();
return;
}
body += chunk;
});
req.on('end', function () { resolve(body); });
req.on('error', reject);
});
}
function parseInitialConfigFromHtml(html) {
const match = html.match(/window\.__INITIAL_CONFIG__\s*=\s*([\s\S]*?);\s*(?:\n|<\/script>)/);
if (!match) return null;
try {
return JSON.parse(match[1]);
} catch (e) {
return null;
}
}
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
try {
if (req.method === 'GET' && (pathname === '/' || pathname === '/editor')) {
const initial = parsed.query.config ? JSON.parse(parsed.query.config) : null;
const html = prepareHtml(initial, 'server');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(html);
return;
}
if (req.method === 'GET' && pathname === '/address-regions.js') {
if (!fs.existsSync(ADDRESS_REGIONS_JS)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('address-regions.js not found');
return;
}
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(fs.readFileSync(ADDRESS_REGIONS_JS, 'utf8'));
return;
}
if (req.method === 'GET' && pathname === '/api/health') {
sendJson(res, 200, { ok: true, service: PROJECT.label, projectId: PROJECT.projectId });
return;
}
if (req.method === 'POST' && pathname === '/api/export') {
const body = await readBody(req);
const config = JSON.parse(body);
config.exportedAt = new Date().toISOString();
config.projectId = PROJECT.projectId;
const html = prepareHtml(config, 'standalone');
const filename = sanitizeFilename(config.approvalName) + '.html';
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Disposition': 'attachment; filename="' + encodeURIComponent(filename) + '"',
'Cache-Control': 'no-store'
});
res.end(html);
return;
}
if (req.method === 'POST' && pathname === '/api/import') {
const body = await readBody(req);
const payload = JSON.parse(body);
let config = null;
if (payload.config) {
config = payload.config;
} else if (payload.html) {
config = parseInitialConfigFromHtml(payload.html);
}
if (!config) {
sendJson(res, 400, { ok: false, error: 'invalid import payload' });
return;
}
sendJson(res, 200, { ok: true, config });
return;
}
if (req.method === 'POST' && pathname === '/api/reload-template') {
reloadTemplate();
sendJson(res, 200, { ok: true });
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
} catch (err) {
sendJson(res, 500, { ok: false, error: err.message || 'server error' });
}
});
server.listen(PORT, HOST, () => {
console.log('[' + (PROJECT.label || 'approval') + '] http://' + HOST + ':' + PORT);
});