NodeJS 转表工具实现

This commit is contained in:
2020-05-17 23:58:31 +08:00
parent c7f9bfbd61
commit 37aa621c6c
13 changed files with 646 additions and 5 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
dist/
node_modules/
yarn.lock
yarn.lock
*.xlsl

5
.vscode/launch.json vendored
View File

@@ -8,7 +8,10 @@
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/dist/binary.js"
"program": "${workspaceFolder}/dist/binary.js",
"args": [
"./excel-exporter.json"
]
}
]
}

View File

@@ -1,3 +1,62 @@
# NodeJS 起始项目
# Excel 配置表数据导出工具
搭建好 TypeScript NodeJS 的空项目,提供编译、调试流程,内置 tiny 使用代码库
将 Excel 配置表中的数据导出为方便程序读取和使用的数据
## 目前支持的导出格式有:
* JSON 文件
* C# 类型声明
~~* TypeScript 声明文件(需要配合 JSON 使用)~~
~~* Godot 引擎的 GDScript 脚本文件~~
## 表格格式说明
* 每个 xlsl 文件中可以有多张表Sheet每张表会都导出一份数据文件表名必须符合标识符规范
* 表名为 `@skip` 或以 `@skip` 开头的表会被忽略,不会导出数据文件
* 第一列值为 `@skip` 的行会被忽略,视为无效数据行
* 整行所有列为空的行会被忽略,视为无效数据行
* 每张表的**第一个有效数据行**用作字段名,决定了导出数据所拥有的属性,**字段名必须符合标识符命名规范**
* 字段名所在的行中不填名称的列视为空字段,该列的数据在导出时会被忽略
* 相同名称的字段导出时会被合并为数组
* 导出属性的数据类型由**整列所填写的数据类型**决定,支持以下数据类型
* 字符串
* 数值(优先使用整形)
* 布尔值
* 空(`null`)
* 该工具设计原则是简单易用,表格字段可由策划自由调整, 不支持数据引用,暂不支持结构体
## Windows 安装
安装 NodeJS, 注意勾选将 Node 添加到环境变量 `PATH`
## 使用
修改配置表
修改 excel-exporter.json 修改工具配置
双击 转表.bat 执行转换工作
### 配置示例
```json
{
"input": [
{ "file": "装备表.xlsx", "encode": "GBK"},
{ "file": "关卡表.xlsx", "encode": "GBK"},
],
"parser": {
"first_row_as_field_comment": true
},
"output": {
"json": {
"enabled": true,
"directory": "../../client/Assets/Resources/data/json",
"indent": "\t"
},
"csharp": {
"enabled": true,
"directory": "../../client/Assets/Resources/data/csharp",
"namespace": "game.data",
"base_type": "tiny.data.UniqueIDObject",
"file_name": "data",
"ignore_id": true
}
}
}
```

32
excel-exporter.json Normal file
View File

@@ -0,0 +1,32 @@
{
"input": [
{ "file": "士兵表.xlsx", "encode": "GBK"},
{ "file": "统帅表.xlsx", "encode": "GBK"},
{ "file": "武器表.xlsx", "encode": "GBK"},
{ "file": "装备表.xlsx", "encode": "GBK"},
{ "file": "关卡表.xlsx", "encode": "GBK"},
{ "file": "箱子奖励招募表.xlsx", "encode": "GBK"},
{ "file": "僵尸表.xlsx", "encode": "GBK"},
{ "file": "任务表.xlsx", "encode": "GBK"},
{ "file": "伤害动作表.xlsx", "encode": "GBK"},
{ "file": "签到表.xlsx", "encode": "GBK"}
],
"parser": {
"first_row_as_field_comment": true
},
"output": {
"json": {
"enabled": true,
"directory": "../../client/Assets/Resources/data/json",
"indent": "\t"
},
"csharp": {
"enabled": true,
"directory": "../../client/Assets/Resources/data/csharp",
"namespace": "game.data",
"base_type": "tiny.data.UniqueIDObject",
"file_name": "data",
"ignore_id": true
}
}
}

View File

@@ -14,5 +14,9 @@
"typescript": "^3.9.2",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"dependencies": {
"colors": "^1.4.0",
"xlsx": "^0.16.0"
}
}

View File

@@ -0,0 +1,71 @@
import { FileAccess, ModeFlags } from "tiny/io";
import { ParserConfigs, TableParser, TableData } from "./TableParser";
import { ExporterConfigs, TableExporter } from "./TableExporter";
import { JSONExporter } from "./exporters/JSONExporter";
import { CSharpExporter } from "./exporters/CSharpExporter";
import * as colors from "colors";
export interface Configurations {
/** 解析配置 */
parser?: ParserConfigs,
/** 要读取的 XLSL 文档 */
input: {"file": string, encode: string}[],
/** 导出配置 */
output: { [key: string]: ExporterConfigs }
}
const exporters: {[key:string]: new(config: ExporterConfigs) => TableExporter } = {
json: JSONExporter,
csharp: CSharpExporter,
}
export class ExcelExporterApplication {
configs: Configurations = null;
parser: TableParser = null;
tables: { [key: string]: TableData } = {};
exporters: TableExporter[] = [];
constructor(config_file: string) {
let file = FileAccess.open(config_file, ModeFlags.READ);
this.configs = JSON.parse(file.get_as_utf8_string()) as Configurations;
file.close();
this.parser = new TableParser(this.configs.parser);
for (const key in this.configs.output) {
let cls = exporters[key];
if (cls) {
const exporter = new cls(this.configs.output[key]);
exporter.name = key;
this.exporters.push(exporter);
}
}
}
parse() {
for (const item of this.configs.input) {
console.log(colors.grey(`解析配表文件: ${item.file}`));
let sheets = this.parser.parse_xlsl(item.file);
for (const name in sheets) {
this.tables[name] = sheets[name];
}
}
console.log(colors.green(`解析所有配表文件完成`));
console.log();
}
export() {
for (const exporter of this.exporters) {
if (exporter.configs.enabled) {
console.log(colors.white(`执行 ${exporter.name} 导出:`));
for (const name in this.tables) {
exporter.export(name, this.tables[name]);
}
exporter.finalize();
console.log();
}
}
}
}

View File

@@ -0,0 +1,46 @@
import { TableData } from "./TableParser";
import { FileAccess, ModeFlags, DirAccess } from "tiny/io";
import { path } from "tiny/path";
export interface ExporterConfigs {
enabled: boolean,
directory: string,
}
export class TableExporter {
configs: ExporterConfigs = null;
name: string = "";
constructor(configs: ExporterConfigs) {
this.configs = configs;
}
protected line(text = "", indent = 0) {
let line = "";
for (let i = 0; i < indent; i++) {
line += "\t";
}
line += text;
line += "\n";
return line;
}
protected save_text(file_path: string, text: string) {
let dir = path.dirname(file_path);
if (!DirAccess.exists(dir)) {
DirAccess.make_dir(dir, true);
}
let file = FileAccess.open(file_path, ModeFlags.WRITE);
file.save_as_utf8_string(text);
file.close();
}
/**
* 导出配置表数据
* @param name 表名称
* @param table 表数据
*/
export(name: string, table: TableData) { }
/** 全部配置表导出完毕后保存文件 */
finalize() {}
}

View File

@@ -0,0 +1,255 @@
import * as xlsl from "xlsx";
import { FileAccess, ModeFlags } from "tiny/io";
import * as colors from "colors";
type RawTableData = xlsl.CellObject[][];
export interface ParserConfigs {
/** 第一行作为注释 */
first_row_as_field_comment: boolean;
/** 固定数组长度 */
constant_array_length: boolean;
}
export enum DataType {
null = 'null',
int = 'int',
bool = 'bool',
float = 'float',
string = 'string',
}
export interface ColumnDescription {
type: DataType;
name: string;
is_array?: boolean;
comment?: string;
}
export interface TableData {
headers: ColumnDescription[],
values: any[][]
}
const SKIP_PREFIX = "@skip";
export class TableParser {
configs: ParserConfigs = null;
constructor(configs: ParserConfigs) {
this.configs = configs;
}
public parse_xlsl(path) {
return this.load_raw_xlsl_data(path);
}
protected load_raw_xlsl_data(path: string): { [key: string]: TableData } {
var file = FileAccess.open(path, ModeFlags.READ);
let wb = xlsl.read(file.get_as_array());
file.close();
let raw_tables: { [key: string]: RawTableData } = {};
for (const name of wb.SheetNames) {
let sheet_name = name.trim();
if (sheet_name.startsWith(SKIP_PREFIX)) continue;
raw_tables[sheet_name] = this.parse_sheet(wb.Sheets[name]);
}
let tables: { [key: string]: TableData } = {};
for (const name in raw_tables) {
console.log(colors.grey(`\t解析配置表 ${name}`));
tables[name] = this.process_table(raw_tables[name]);
}
return tables;
}
protected parse_sheet(sheet: xlsl.WorkSheet): RawTableData {
let range = xlsl.utils.decode_range(sheet['!ref']);
var rows: RawTableData = [];
for (let r = range.s.r; r <= range.e.r; r++) {
let R = xlsl.utils.encode_row(r);
let row: xlsl.CellObject[] = [];
for (let c = range.s.c; c <= range.e.c; c++) {
let C = xlsl.utils.encode_col(c);
let cell = sheet[`${C}${R}`] as xlsl.CellObject;
row.push(cell);
}
rows.push(row);
}
return rows;
}
protected process_table(raw: RawTableData): TableData {
let headers: ColumnDescription[] = [];
let column_values: xlsl.CellObject[][] = [];
let ignored_columns = new Set<number>();
// 去除无用的列
let rows: RawTableData = [];
for (const row of raw) {
if (this.is_valid_row(row)) {
rows.push(row);
}
}
let column = 0;
for (let c = 0; c < rows[0].length; c++) {
let first = rows[0][c];
if (this.get_data_type(first) != DataType.string) {
ignored_columns.add(c);
continue;
}
let column_cells = this.get_column(rows, c, 1);
let type = DataType.null;
let types = new Set<DataType>();
for (const cell of column_cells) {
types.add(this.get_data_type(cell));
}
let type_order = [ DataType.string, DataType.float, DataType.int, DataType.bool ];
for (const t of type_order) {
if (types.has(t)) {
type = t;
break;
}
}
let comment: string = undefined;
if (this.configs.first_row_as_field_comment) {
comment = this.get_cell_value(raw[0][c], DataType.string) as string;
}
headers.push({
type,
comment,
name: first.v as string,
});
column_values.push([]);
for (const cell of column_cells) {
column_values[column].push(cell);
}
column += 1;
}
let values: RawTableData = [];
for (let r = 0; r < rows.length - 1; r++) {
let row: any = [];
for (let c = 0; c < column_values.length; c++) {
row.push(column_values[c][r])
}
values.push(row);
}
return this.parse_values(headers, values);
}
protected parse_values(raw_headers : ColumnDescription[], raw_values: RawTableData) {
type FiledInfo = {
column: ColumnDescription,
start: number,
indexes: number[]
};
let field_maps = new Map<string, FiledInfo>();
let field_list: FiledInfo[] = [];
let c_idx = 0;
for (const column of raw_headers) {
if (!field_maps.has(column.name)) {
const field = {
column,
start: c_idx,
indexes: [ c_idx ]
};
field_list.push(field);
field_maps.set(column.name, field);
} else {
let field = field_maps.get(column.name);
field.column.is_array = true;
field.indexes.push(c_idx);
}
c_idx += 1;
}
let headers: ColumnDescription[] = [];
for (const filed of field_list) {
headers.push(filed.column);
}
let values: any[][] = [];
for (const raw_row of raw_values) {
let row: any[] = [];
for (const filed of field_list) {
if (filed.column.is_array) {
let arr = [];
for (const idx of filed.indexes) {
const cell = raw_row[idx];
if (cell || this.configs.constant_array_length) {
arr.push(this.get_cell_value(cell, filed.column.type));
}
}
row.push(arr);
} else {
const cell = raw_row[filed.start];
row.push(this.get_cell_value(cell, filed.column.type));
}
}
values.push(row);
}
return {
headers,
values
}
}
protected is_valid_row(row: xlsl.CellObject[]) {
let first = row[0];
if (this.get_data_type(first) == DataType.string && (first.v as string).trim().startsWith(SKIP_PREFIX)) {
return false;
}
let all_empty = true;
for (const cell of row) {
all_empty = all_empty && this.get_data_type(cell) == DataType.null;
}
if (all_empty) return false;
return true;
}
protected get_column(table: RawTableData, column: number, start_row: number = 0): xlsl.CellObject[] {
let cells: xlsl.CellObject[] = [];
for (let r = start_row; r < table.length; r++) {
const row = table[r];
cells.push(row[column]);
}
return cells;
}
protected get_data_type(cell: xlsl.CellObject): DataType {
if (!cell) return DataType.null;
switch (cell.t) {
case 'b':
return DataType.bool;
case 'n':
return Number.isInteger(cell.v as number) ? DataType.int : DataType.float;
case 's':
case 'd':
return DataType.string;
case 'e':
case 'z':
default:
return DataType.null;
}
}
protected get_cell_value(cell: xlsl.CellObject, type: DataType) {
switch (type) {
case DataType.bool:
return cell.v as boolean == true;
case DataType.int:
return cell ? cell.v as number : 0;
case DataType.float:
return cell ? cell.v as number : 0;
case DataType.string:
return cell ? cell.v + '' : '';
default:
return null;
}
}
}

View File

@@ -0,0 +1,85 @@
import { TableExporter, ExporterConfigs } from "excel-exporter/TableExporter";
import { TableData, DataType } from "excel-exporter/TableParser";
import { path } from "tiny/path";
import * as colors from "colors";
interface CSharpExporterConfigs extends ExporterConfigs {
namespace: string,
base_type: string,
file_name: string,
ignore_id: boolean
}
export class CSharpExporter extends TableExporter {
protected declear_content = "";
protected classes: string[] = [];
constructor(configs: ExporterConfigs) {
super(configs);
if ( typeof ((this.configs as CSharpExporterConfigs).namespace) != 'string') {
(this.configs as CSharpExporterConfigs).namespace = "game.data";
}
if ( typeof ((this.configs as CSharpExporterConfigs).base_type) != 'string') {
(this.configs as CSharpExporterConfigs).namespace = "object";
}
if ( typeof ((this.configs as CSharpExporterConfigs).file_name) != 'string') {
(this.configs as CSharpExporterConfigs).file_name = "data";
}
this.declear_content += this.line("// Tool generated file DO NOT MODIFY");
this.declear_content += this.line("using System;");
this.declear_content += this.line();
this.declear_content += this.line("namespace " + (this.configs as CSharpExporterConfigs).namespace + " {")
this.declear_content += this.line("%CLASSES%");
this.declear_content += this.line("}");
}
export(name: string, table: TableData) {
const base_type = (this.configs as CSharpExporterConfigs).base_type;
let body = "";
for (const field of table.headers) {
if (field.name == 'id' && (this.configs as CSharpExporterConfigs).ignore_id) {
continue;
}
let type = "object";
switch (field.type) {
case DataType.bool:
case DataType.float:
case DataType.string:
case DataType.int:
type = field.type;
break;
default:
type = "object";
break;
}
if (field.is_array) {
type += "[]";
}
if (field.comment) {
let comment = field.comment.split("\r\n").join("\t");
comment = comment.split("\n").join("\t");
body += this.line(`/// <summary>${comment}</summary>`, 1);
}
body += this.line(`${type} ${field.name};`, 1);
}
let class_text = this.line(`public class ${name} : ${base_type} {\n${body}\n}`);
this.classes.push(class_text);
}
finalize() {
let class_text = "";
for (const cls of this.classes) {
class_text += cls;
class_text += this.line();
}
let file = path.join(this.configs.directory, (this.configs as CSharpExporterConfigs).file_name);
if (!file.endsWith(".cs")) {
file += ".cs";
}
this.save_text(file, this.declear_content.replace("%CLASSES%", class_text));
console.log(colors.green(`\t${file}`));
}
}

View File

@@ -0,0 +1,67 @@
import { TableExporter, ExporterConfigs } from "excel-exporter/TableExporter";
import { TableData } from "excel-exporter/TableParser";
import { path } from "tiny/path";
import * as colors from "colors";
interface JSONExporterConfigs extends ExporterConfigs {
/** 缩进字符 */
indent: string;
}
export class JSONExporter extends TableExporter {
constructor(configs: ExporterConfigs) {
super(configs);
if ( typeof ((this.configs as JSONExporterConfigs).indent) != 'string') {
(this.configs as JSONExporterConfigs).indent = " ";
}
}
protected recursively_order_keys(unordered: object | Array<object>) {
// If it's an array - recursively order any
// dictionary items within the array
if (Array.isArray(unordered)) {
unordered.forEach((item, index) => {
unordered[index] = this.recursively_order_keys(item);
});
return unordered;
}
// If it's an object - let's order the keys
if (typeof unordered === 'object' && unordered != null) {
var ordered = {};
Object.keys(unordered).sort().forEach((key) => {
ordered[key] = this.recursively_order_keys(unordered[key]);
});
return ordered;
}
return unordered;
}
export(name: string, table: TableData) {
const file = path.join(this.configs.directory, name + ".json");
let headers = table.headers;
let values = [];
for (const row of table.values) {
let new_row = {};
for (let i = 0; i < headers.length; i++) {
const field = headers[i];
new_row[field.name] = row[i];
}
values.push(new_row);
}
let indent = "";
const configs = (this.configs as JSONExporterConfigs);
if (configs.indent) {
if (typeof (configs.indent) == 'number') {
for (let i = 0; i < configs.indent; i++) {
indent += " ";
}
} else if (typeof configs.indent == 'string') {
indent = configs.indent;
}
}
const text = JSON.stringify(this.recursively_order_keys(values), null, indent);
this.save_text(file, text);
console.log(colors.green(`\t ${name} ==> ${file}`));
}
}

View File

@@ -1,4 +1,18 @@
import { get_startup_arguments } from "./tiny/env";
import { ExcelExporterApplication } from "excel-exporter/ExcelExporterApplication";
import { FileAccess } from "tiny/io";
import * as colors from "colors";
(async function main(argv: string[]) {
console.log(argv);
let config_file = argv[argv.length - 1];
if (config_file.endsWith(".json") && FileAccess.exists(config_file)) {
let app = new ExcelExporterApplication(config_file);
app.parse();
app.export();
console.log(colors.green("All Done"));
} else {
console.log(colors.red("请传入配置文件作为参数"));
}
})(get_startup_arguments());

2
转表.bat Normal file
View File

@@ -0,0 +1,2 @@
call node ./dist/binary.js ./excel-exporter.json
pause

2
转表.sh Normal file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
node ./dist/binary.js ./excel-exporter.json