688 lines
18 KiB
TypeScript
688 lines
18 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* ICP备案源代码导出脚本
|
||
*
|
||
* 功能:
|
||
* 1. 执行 expo prebuild 生成原生代码
|
||
* 2. 收集源代码文件
|
||
* 3. 按ICP备案要求导出到DOCX文档
|
||
*
|
||
* 要求:
|
||
* - 前后30页,共60页,每页不少于50行
|
||
* - 必须有开头结尾、最后一页应是结束页
|
||
* - 不足60页的需要全部提交
|
||
*
|
||
* 使用方式:
|
||
* bun run scripts/export-icp-source.ts --platform android
|
||
* bun run scripts/export-icp-source.ts --platform ios
|
||
* bun run scripts/export-icp-source.ts --platform android --skip-prebuild
|
||
*/
|
||
|
||
import { $ } from "bun";
|
||
import * as fs from "fs";
|
||
import * as path from "path";
|
||
import {
|
||
Document,
|
||
Packer,
|
||
Paragraph,
|
||
TextRun,
|
||
PageBreak,
|
||
Header,
|
||
Footer,
|
||
AlignmentType,
|
||
PageNumber,
|
||
NumberFormat,
|
||
} from "docx";
|
||
|
||
// 配置
|
||
const CONFIG = {
|
||
LINES_PER_PAGE: 50, // 每页最少行数
|
||
TOTAL_PAGES: 60, // 总页数要求
|
||
FRONT_PAGES: 30, // 前30页
|
||
BACK_PAGES: 30, // 后30页
|
||
FONT_SIZE: 18, // 字号 (half-points, 18 = 9pt)
|
||
FONT_NAME: "Consolas", // 等宽字体
|
||
// 原生代码扩展名(只包含核心源码)
|
||
NATIVE_EXTENSIONS: [
|
||
".kt", ".java", // Android Kotlin/Java 源码
|
||
".swift", ".m", ".h", // iOS Swift/ObjC 源码
|
||
],
|
||
// JS/TS 源代码扩展名
|
||
JS_EXTENSIONS: [
|
||
".js", ".ts", ".tsx", ".jsx",
|
||
],
|
||
// 排除的目录
|
||
EXCLUDE_DIRS: [
|
||
"node_modules", ".gradle", "build", "Pods", ".git",
|
||
"DerivedData", "__pycache__", ".idea", ".vscode",
|
||
"assets", ".expo", "dist", "scripts",
|
||
],
|
||
// 排除的文件名模式
|
||
EXCLUDE_FILES: [
|
||
".gradle", ".properties", ".xml", ".plist", ".pbxproj",
|
||
".json", ".lock", ".md", ".css", ".png", ".jpg",
|
||
],
|
||
};
|
||
|
||
// 解析命令行参数
|
||
function parseArgs(): { platform: "android" | "ios"; skipPrebuild: boolean } {
|
||
const args = process.argv.slice(2);
|
||
let platform: "android" | "ios" = "android";
|
||
let skipPrebuild = false;
|
||
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === "--platform" && args[i + 1]) {
|
||
const p = args[i + 1].toLowerCase();
|
||
if (p === "android" || p === "ios") {
|
||
platform = p;
|
||
}
|
||
i++;
|
||
}
|
||
if (args[i] === "--skip-prebuild") {
|
||
skipPrebuild = true;
|
||
}
|
||
}
|
||
|
||
return { platform, skipPrebuild };
|
||
}
|
||
|
||
// 执行 expo prebuild
|
||
async function runPrebuild(platform: "android" | "ios"): Promise<void> {
|
||
console.log(`🔧 正在执行 expo prebuild --platform ${platform}...`);
|
||
|
||
const projectDir = path.resolve(import.meta.dir, "..");
|
||
|
||
try {
|
||
await $`bunx expo prebuild --platform ${platform} --clean`.cwd(projectDir);
|
||
console.log(`✅ prebuild 完成`);
|
||
} catch (error) {
|
||
console.error(`❌ prebuild 失败:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 收集原生源代码文件(.kt, .swift 等)
|
||
function collectNativeSourceFiles(dir: string, files: string[] = []): string[] {
|
||
if (!fs.existsSync(dir)) {
|
||
return files;
|
||
}
|
||
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
if (!CONFIG.EXCLUDE_DIRS.includes(entry.name)) {
|
||
collectNativeSourceFiles(fullPath, files);
|
||
}
|
||
} else if (entry.isFile()) {
|
||
const ext = path.extname(entry.name).toLowerCase();
|
||
if (CONFIG.NATIVE_EXTENSIONS.includes(ext)) {
|
||
files.push(fullPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
return files;
|
||
}
|
||
|
||
// 收集 JS/TS 源代码文件
|
||
function collectJsSourceFiles(dir: string, files: string[] = []): string[] {
|
||
if (!fs.existsSync(dir)) {
|
||
return files;
|
||
}
|
||
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
if (!CONFIG.EXCLUDE_DIRS.includes(entry.name)) {
|
||
collectJsSourceFiles(fullPath, files);
|
||
}
|
||
} else if (entry.isFile()) {
|
||
const ext = path.extname(entry.name).toLowerCase();
|
||
if (CONFIG.JS_EXTENSIONS.includes(ext)) {
|
||
files.push(fullPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
return files;
|
||
}
|
||
|
||
// 读取文件内容并处理(过滤空行)
|
||
function readFileContent(filePath: string, baseDir: string): { relativePath: string; lines: string[] } {
|
||
const content = fs.readFileSync(filePath, "utf-8");
|
||
const relativePath = path.relative(baseDir, filePath).replace(/\\/g, "/");
|
||
// 过滤空行和只有空白字符的行
|
||
const lines = content.split("\n").filter(line => line.trim().length > 0);
|
||
return { relativePath, lines };
|
||
}
|
||
|
||
// 将所有源代码整合成行数组
|
||
interface SourceLine {
|
||
content: string;
|
||
filePath: string;
|
||
lineNumber: number;
|
||
isFileHeader: boolean;
|
||
isFileEnd: boolean;
|
||
}
|
||
|
||
function consolidateSourceCode(files: string[], baseDir: string): SourceLine[] {
|
||
const allLines: SourceLine[] = [];
|
||
|
||
for (const file of files) {
|
||
const { relativePath, lines } = readFileContent(file, baseDir);
|
||
|
||
// 添加文件头
|
||
allLines.push({
|
||
content: `${"=".repeat(60)}`,
|
||
filePath: relativePath,
|
||
lineNumber: 0,
|
||
isFileHeader: true,
|
||
isFileEnd: false,
|
||
});
|
||
allLines.push({
|
||
content: `文件: ${relativePath}`,
|
||
filePath: relativePath,
|
||
lineNumber: 0,
|
||
isFileHeader: true,
|
||
isFileEnd: false,
|
||
});
|
||
allLines.push({
|
||
content: `${"=".repeat(60)}`,
|
||
filePath: relativePath,
|
||
lineNumber: 0,
|
||
isFileHeader: true,
|
||
isFileEnd: false,
|
||
});
|
||
|
||
// 添加文件内容
|
||
for (let i = 0; i < lines.length; i++) {
|
||
allLines.push({
|
||
content: lines[i],
|
||
filePath: relativePath,
|
||
lineNumber: i + 1,
|
||
isFileHeader: false,
|
||
isFileEnd: i === lines.length - 1,
|
||
});
|
||
}
|
||
|
||
// 标记最后一行为文件结束
|
||
if (allLines.length > 0) {
|
||
allLines[allLines.length - 1].isFileEnd = true;
|
||
}
|
||
}
|
||
|
||
return allLines;
|
||
}
|
||
|
||
// 将行分成页
|
||
interface Page {
|
||
pageNumber: number;
|
||
lines: SourceLine[];
|
||
isFirstPage: boolean;
|
||
isLastPage: boolean;
|
||
}
|
||
|
||
function paginateLines(allLines: SourceLine[], linesPerPage: number): Page[] {
|
||
const pages: Page[] = [];
|
||
let currentPageLines: SourceLine[] = [];
|
||
let pageNumber = 1;
|
||
|
||
for (const line of allLines) {
|
||
currentPageLines.push(line);
|
||
|
||
if (currentPageLines.length >= linesPerPage) {
|
||
pages.push({
|
||
pageNumber,
|
||
lines: [...currentPageLines],
|
||
isFirstPage: pageNumber === 1,
|
||
isLastPage: false,
|
||
});
|
||
currentPageLines = [];
|
||
pageNumber++;
|
||
}
|
||
}
|
||
|
||
// 处理最后一页(可能不足 linesPerPage 行)
|
||
if (currentPageLines.length > 0) {
|
||
pages.push({
|
||
pageNumber,
|
||
lines: currentPageLines,
|
||
isFirstPage: pageNumber === 1,
|
||
isLastPage: true,
|
||
});
|
||
} else if (pages.length > 0) {
|
||
pages[pages.length - 1].isLastPage = true;
|
||
}
|
||
|
||
return pages;
|
||
}
|
||
|
||
// 选择要导出的页面(前30页 + 后30页,或全部)
|
||
function selectPages(allPages: Page[]): Page[] {
|
||
const totalPages = allPages.length;
|
||
|
||
if (totalPages <= CONFIG.TOTAL_PAGES) {
|
||
// 不足60页,全部提交
|
||
console.log(`📄 总共 ${totalPages} 页,不足60页,将全部导出`);
|
||
return allPages;
|
||
}
|
||
|
||
// 超过60页,取前30页和后30页
|
||
console.log(`📄 总共 ${totalPages} 页,将导出前30页和后30页`);
|
||
|
||
const frontPages = allPages.slice(0, CONFIG.FRONT_PAGES);
|
||
const backPages = allPages.slice(-CONFIG.BACK_PAGES);
|
||
|
||
// 重新编号后30页(标记为省略后的页码)
|
||
const selectedPages = [
|
||
...frontPages,
|
||
...backPages.map((page, index) => ({
|
||
...page,
|
||
pageNumber: CONFIG.FRONT_PAGES + index + 1,
|
||
})),
|
||
];
|
||
|
||
// 确保最后一页标记正确
|
||
selectedPages[selectedPages.length - 1].isLastPage = true;
|
||
|
||
return selectedPages;
|
||
}
|
||
|
||
// 创建封面页
|
||
function createCoverPage(platform: string, totalFiles: number, totalLines: number, totalPages: number): Paragraph[] {
|
||
return [
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 2000, after: 400 },
|
||
children: [
|
||
new TextRun({
|
||
text: "软件源代码",
|
||
bold: true,
|
||
size: 56,
|
||
font: "SimHei",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 400, after: 200 },
|
||
children: [
|
||
new TextRun({
|
||
text: "(ICP备案材料)",
|
||
size: 32,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 800, after: 200 },
|
||
children: [
|
||
new TextRun({
|
||
text: `平台: ${platform === "android" ? "Android" : "iOS"}`,
|
||
size: 28,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 200, after: 200 },
|
||
children: [
|
||
new TextRun({
|
||
text: `源文件数: ${totalFiles}`,
|
||
size: 28,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 200, after: 200 },
|
||
children: [
|
||
new TextRun({
|
||
text: `总行数: ${totalLines}`,
|
||
size: 28,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 200, after: 200 },
|
||
children: [
|
||
new TextRun({
|
||
text: `导出页数: ${totalPages}`,
|
||
size: 28,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 800, after: 200 },
|
||
children: [
|
||
new TextRun({
|
||
text: `生成时间: ${new Date().toLocaleString("zh-CN")}`,
|
||
size: 24,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
children: [new PageBreak()],
|
||
}),
|
||
];
|
||
}
|
||
|
||
// 创建分隔页(前30页和后30页之间)
|
||
function createSeparatorPage(skippedPages: number): Paragraph[] {
|
||
return [
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 3000, after: 400 },
|
||
children: [
|
||
new TextRun({
|
||
text: "(此处省略中间部分)",
|
||
bold: true,
|
||
size: 36,
|
||
font: "SimHei",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 400, after: 400 },
|
||
children: [
|
||
new TextRun({
|
||
text: `省略页数: ${skippedPages} 页`,
|
||
size: 28,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 400, after: 400 },
|
||
children: [
|
||
new TextRun({
|
||
text: "以下为源代码最后30页内容",
|
||
size: 28,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
children: [new PageBreak()],
|
||
}),
|
||
];
|
||
}
|
||
|
||
// 创建结束页
|
||
function createEndPage(): Paragraph[] {
|
||
return [
|
||
new Paragraph({
|
||
children: [new PageBreak()],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 3000, after: 400 },
|
||
children: [
|
||
new TextRun({
|
||
text: "源代码结束",
|
||
bold: true,
|
||
size: 48,
|
||
font: "SimHei",
|
||
}),
|
||
],
|
||
}),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 400, after: 200 },
|
||
children: [
|
||
new TextRun({
|
||
text: "— END —",
|
||
size: 32,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
];
|
||
}
|
||
|
||
// 创建代码页面内容
|
||
function createCodePageContent(page: Page): Paragraph[] {
|
||
const paragraphs: Paragraph[] = [];
|
||
|
||
for (const line of page.lines) {
|
||
let displayText = line.content;
|
||
|
||
// 如果不是文件头,添加行号
|
||
if (!line.isFileHeader && line.lineNumber > 0) {
|
||
const lineNumStr = String(line.lineNumber).padStart(5, " ");
|
||
displayText = `${lineNumStr} | ${line.content}`;
|
||
}
|
||
|
||
paragraphs.push(
|
||
new Paragraph({
|
||
spacing: { after: 20, line: 240 },
|
||
children: [
|
||
new TextRun({
|
||
text: displayText || " ", // 空行显示空格
|
||
size: CONFIG.FONT_SIZE,
|
||
font: CONFIG.FONT_NAME,
|
||
}),
|
||
],
|
||
})
|
||
);
|
||
}
|
||
|
||
// 添加分页符(除了最后一页)
|
||
if (!page.isLastPage) {
|
||
paragraphs.push(
|
||
new Paragraph({
|
||
children: [new PageBreak()],
|
||
})
|
||
);
|
||
}
|
||
|
||
return paragraphs;
|
||
}
|
||
|
||
// 生成DOCX文档
|
||
async function generateDocx(
|
||
platform: string,
|
||
selectedPages: Page[],
|
||
totalFiles: number,
|
||
totalLines: number,
|
||
allPagesCount: number,
|
||
outputPath: string
|
||
): Promise<void> {
|
||
const allParagraphs: Paragraph[] = [];
|
||
|
||
// 1. 添加封面
|
||
allParagraphs.push(...createCoverPage(platform, totalFiles, totalLines, selectedPages.length));
|
||
|
||
// 2. 添加代码页
|
||
const needsSeparator = allPagesCount > CONFIG.TOTAL_PAGES;
|
||
const skippedPages = allPagesCount - CONFIG.TOTAL_PAGES;
|
||
|
||
for (let i = 0; i < selectedPages.length; i++) {
|
||
const page = selectedPages[i];
|
||
|
||
// 如果需要分隔页,在前30页之后添加
|
||
if (needsSeparator && i === CONFIG.FRONT_PAGES) {
|
||
allParagraphs.push(...createSeparatorPage(skippedPages));
|
||
}
|
||
|
||
allParagraphs.push(...createCodePageContent(page));
|
||
}
|
||
|
||
// 3. 添加结束页
|
||
allParagraphs.push(...createEndPage());
|
||
|
||
// 创建文档
|
||
const doc = new Document({
|
||
sections: [
|
||
{
|
||
properties: {
|
||
page: {
|
||
margin: {
|
||
top: 720, // 0.5 inch
|
||
right: 720,
|
||
bottom: 720,
|
||
left: 720,
|
||
},
|
||
},
|
||
},
|
||
headers: {
|
||
default: new Header({
|
||
children: [
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
children: [
|
||
new TextRun({
|
||
text: `${platform === "android" ? "Android" : "iOS"} 源代码 - ICP备案材料`,
|
||
size: 20,
|
||
font: "SimSun",
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
}),
|
||
},
|
||
footers: {
|
||
default: new Footer({
|
||
children: [
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
children: [
|
||
new TextRun({
|
||
text: "第 ",
|
||
size: 20,
|
||
}),
|
||
new TextRun({
|
||
children: [PageNumber.CURRENT],
|
||
size: 20,
|
||
}),
|
||
new TextRun({
|
||
text: " 页",
|
||
size: 20,
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
}),
|
||
},
|
||
children: allParagraphs,
|
||
},
|
||
],
|
||
});
|
||
|
||
// 保存文档
|
||
const buffer = await Packer.toBuffer(doc);
|
||
fs.writeFileSync(outputPath, buffer);
|
||
console.log(`✅ 文档已保存到: ${outputPath}`);
|
||
}
|
||
|
||
// 主函数
|
||
async function main(): Promise<void> {
|
||
console.log("🚀 ICP备案源代码导出工具");
|
||
console.log("========================\n");
|
||
|
||
const { platform, skipPrebuild } = parseArgs();
|
||
const projectDir = path.resolve(import.meta.dir, "..");
|
||
|
||
console.log(`📱 目标平台: ${platform}`);
|
||
console.log(`📁 项目目录: ${projectDir}\n`);
|
||
|
||
// 1. 执行 prebuild(如果没有跳过)
|
||
if (!skipPrebuild) {
|
||
await runPrebuild(platform);
|
||
} else {
|
||
console.log("⏭️ 跳过 prebuild 步骤\n");
|
||
}
|
||
|
||
// 2. 确定源代码目录
|
||
const nativeDir = path.join(projectDir, platform);
|
||
if (!fs.existsSync(nativeDir)) {
|
||
console.error(`❌ 原生代码目录不存在: ${nativeDir}`);
|
||
console.error(`请先运行 expo prebuild --platform ${platform}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// 3. 收集源代码文件
|
||
console.log("📂 正在收集源代码文件...");
|
||
|
||
// 收集原生代码 (.kt, .swift 等)
|
||
const nativeFiles = collectNativeSourceFiles(nativeDir);
|
||
console.log(` 原生源码: ${nativeFiles.length} 个文件 (${platform === "android" ? ".kt/.java" : ".swift/.m/.h"})`);
|
||
|
||
// 收集 JS/TS 源代码(app, components, hooks, utils, ble 等目录)
|
||
const jsDirectories = ["app", "components", "hooks", "utils", "ble", "constants", "@share"];
|
||
let jsFiles: string[] = [];
|
||
for (const dir of jsDirectories) {
|
||
const dirPath = path.join(projectDir, dir);
|
||
if (fs.existsSync(dirPath)) {
|
||
collectJsSourceFiles(dirPath, jsFiles);
|
||
}
|
||
}
|
||
console.log(` JS/TS源码: ${jsFiles.length} 个文件 (.ts/.tsx/.js/.jsx)`);
|
||
|
||
// 合并所有文件(JS源码在前,原生代码在后)
|
||
const files = [...jsFiles, ...nativeFiles];
|
||
console.log(` 总计: ${files.length} 个源文件\n`);
|
||
|
||
if (files.length === 0) {
|
||
console.error("❌ 未找到任何源代码文件");
|
||
process.exit(1);
|
||
}
|
||
|
||
// 4. 整合所有源代码
|
||
console.log("📝 正在整合源代码...");
|
||
const allLines = consolidateSourceCode(files, projectDir);
|
||
console.log(` 共 ${allLines.length} 行\n`);
|
||
|
||
// 5. 分页
|
||
console.log("📄 正在分页...");
|
||
const allPages = paginateLines(allLines, CONFIG.LINES_PER_PAGE);
|
||
console.log(` 共 ${allPages.length} 页\n`);
|
||
|
||
// 6. 选择要导出的页面
|
||
const selectedPages = selectPages(allPages);
|
||
|
||
// 7. 生成输出文件名
|
||
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||
const outputFileName = `ICP备案_源代码_${platform}_${timestamp}.docx`;
|
||
const outputPath = path.join(projectDir, outputFileName);
|
||
|
||
// 8. 生成DOCX
|
||
console.log("\n📖 正在生成DOCX文档...");
|
||
await generateDocx(
|
||
platform,
|
||
selectedPages,
|
||
files.length,
|
||
allLines.length,
|
||
allPages.length,
|
||
outputPath
|
||
);
|
||
|
||
console.log("\n✨ 导出完成!");
|
||
console.log(` 文件: ${outputFileName}`);
|
||
console.log(` 页数: ${selectedPages.length + 2} (含封面和结束页)`);
|
||
console.log(` 源文件数: ${files.length}`);
|
||
console.log(` 总行数: ${allLines.length}`);
|
||
}
|
||
|
||
// 运行
|
||
main().catch((error) => {
|
||
console.error("❌ 导出失败:", error);
|
||
process.exit(1);
|
||
});
|