Compare commits

..
8 Commits
Author SHA1 Message Date
MikiVLandClaude Sonnet 4.6 2c649b8c7c 添加 GitHub Actions 自动构建 Windows/macOS 安装包
Build Windows Installer / build-windows (push) Has been cancelled
构建安装包 / build-windows (push) Has been cancelled
构建安装包 / build-mac (push) Has been cancelled
每次推送 main 分支或手动触发时,分别在
windows-latest 和 macos-latest 上打包 Python 可执行文件
并生成 Electron 安装包,产物上传为 Artifact 保留 30 天。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:42:40 +08:00
MikiVLandClaude Sonnet 4.6 6b2c87dc18 修复 Windows 编码问题导致生成失败
Windows 默认编码为 GBK,Python stdout 输出含中文的 JSON 时
Node.js 端无法正确解析,导致批量生成操作静默失败。

- python/main.py:启动时强制 stdin/stdout 使用 UTF-8
- templateIpc.js:spawn 传入 PYTHONUTF8=1,改用 Buffer 拼接后 UTF-8 解码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:41:55 +08:00
MikiVL a4d1218d21 fix: 显式安装 tailwindcss/oxide Windows 原生绑定
Build Windows Installer / build-windows (push) Waiting to run
2026-05-07 01:22:50 +08:00
MikiVL df2488c653 fix: 构建前删除 package-lock.json,让 Windows 重新解析 tailwindcss 原生绑定
Build Windows Installer / build-windows (push) Waiting to run
2026-05-07 01:04:27 +08:00
MikiVL ec105b3828 fix: 修复 Windows 构建中 tailwindcss 原生绑定找不到的问题,改用 npm install 替代 npm ci
Build Windows Installer / build-windows (push) Waiting to run
2026-05-06 23:59:52 +08:00
MikiVLandClaude Sonnet 4.6 cb2c186f7f 添加 GitHub Actions Windows 构建流程
Build Windows Installer / build-windows (push) Waiting to run
- 新增 .github/workflows/build-windows.yml,在 windows-latest runner 上构建 NSIS 安装包
- 修复 PYTHON_BIN 在 Windows 下需要 .exe 后缀
- 更新 extraResources 为目录式 glob,同时兼容 macOS (main) 和 Windows (main.exe)
- package.json 新增 win/nsis electron-builder 目标配置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 23:02:53 +08:00
MikiVLandClaude Sonnet 4.6 70a56ede36 添加 macOS 安装包打包支持
- 重命名 parser.py → xl_parser.py 避免与 Python 3.9 stdlib 命名冲突
- 添加 PyInstaller spec 文件用于构建独立 Python 可执行文件
- 配置 electron-builder:extraResources 打包 Python binary、asarUnpack better-sqlite3
- 新增 build:python 和 dist 脚本,一键生成 DMG 安装包
- 更新测试:对齐新 fixture 结构和重命名后的模块

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:44:01 +08:00
MikiVLandClaude Sonnet 4.6 9738d2cd67 添加 Playwright E2E 测试及构建修复
- 修复 vite.config.mjs base 路径为 "./" 解决 file:// 协议加载失败
- 添加测试专用 mockDialog IPC 绕过原生文件选择对话框
- 重建 better-sqlite3 以适配 Electron 内嵌 Node ABI
- 新增 6 个 E2E 测试:模板增删改、批量生成、文件解析

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:01:13 +08:00
14 changed files with 1615 additions and 26 deletions
+69
View File
@@ -0,0 +1,69 @@
name: Build Windows Installer
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
build-windows:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Disable Windows Defender real-time protection
run: Set-MpPreference -DisableRealtimeMonitoring $true
shell: powershell
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python dependencies
run: pip install openpyxl==3.1.2 Pillow==10.3.0 pyinstaller
- name: Build Python executable
run: >
pyinstaller
--onefile
--name main
--distpath python/dist
--workpath python/build
--specpath python
--hidden-import xl_parser
--hidden-import generator
--collect-all openpyxl
--collect-all et_xmlfile
--collect-all PIL
python/main.py
- name: Set up Node.js 18
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Install Node dependencies
run: |
Remove-Item package-lock.json -ErrorAction SilentlyContinue
npm install --include=optional
npm install @tailwindcss/oxide-win32-x64-msvc@4.2.4 --no-save
- name: Build renderer
run: npx vite build --config vite.config.mjs
working-directory: renderer
- name: Build Windows installer
run: npx electron-builder --win --x64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: windows-installer
path: dist/*.exe
if-no-files-found: error
retention-days: 30
+91
View File
@@ -0,0 +1,91 @@
name: 构建安装包
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: 安装 Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: 安装 Node 依赖
run: npm ci
- name: 安装 Python 依赖并打包
run: |
cd python
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt pyinstaller
.venv\Scripts\pyinstaller --onefile --name main --distpath dist --workpath build --specpath . --hidden-import xl_parser --hidden-import generator --collect-all openpyxl --collect-all et_xmlfile --collect-all PIL main.py
shell: cmd
- name: 构建 Renderer
run: cd renderer && npx vite build --config vite.config.mjs
- name: 打包 Electron(Windows)
run: npx electron-builder --win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: 上传 Windows 安装包
uses: actions/upload-artifact@v4
with:
name: windows-installer
path: dist/*.exe
retention-days: 30
build-mac:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: 安装 Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: 安装 Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: 安装 Node 依赖
run: npm ci
- name: 安装 Python 依赖并打包
run: |
cd python
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt pyinstaller
.venv/bin/pyinstaller --onefile --name main --distpath dist --workpath build --specpath . --hidden-import xl_parser --hidden-import generator --collect-all openpyxl --collect-all et_xmlfile --collect-all PIL main.py
- name: 构建 Renderer
run: cd renderer && npx vite build --config vite.config.mjs
- name: 打包 Electron(macOS)
run: npx electron-builder --mac
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: false
- name: 上传 macOS 安装包
uses: actions/upload-artifact@v4
with:
name: mac-installer
path: dist/*.dmg
retention-days: 30
+21 -7
View File
@@ -8,22 +8,29 @@ const APPDATA_DIR = path.join(app.getPath("userData"), "excel-batch-editor", "te
fs.mkdirSync(APPDATA_DIR, { recursive: true }); fs.mkdirSync(APPDATA_DIR, { recursive: true });
const PYTHON_BIN = app.isPackaged const PYTHON_BIN = app.isPackaged
? path.join(process.resourcesPath, "python", "main") ? path.join(
process.resourcesPath,
"python",
process.platform === "win32" ? "main.exe" : "main"
)
: path.join(__dirname, "../../python/main.py"); : path.join(__dirname, "../../python/main.py");
function callPython(payload) { function callPython(payload) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const isPackaged = app.isPackaged; const isPackaged = app.isPackaged;
// PYTHONUTF8=1 确保 Windows 上 Python 使用 UTF-8 编码
const spawnEnv = { ...process.env, PYTHONUTF8: "1" };
const proc = isPackaged const proc = isPackaged
? spawn(PYTHON_BIN) ? spawn(PYTHON_BIN, [], { env: spawnEnv })
: spawn(process.env.PYTHON_PATH || "python3", [PYTHON_BIN]); : spawn(process.env.PYTHON_PATH || "python3", [PYTHON_BIN], { env: spawnEnv });
let stdout = ""; const chunks = [];
proc.stdout.on("data", (d) => (stdout += d)); proc.stdout.on("data", (d) => chunks.push(d));
proc.stderr.on("data", (d) => console.error("[python]", d.toString())); proc.stderr.on("data", (d) => console.error("[python]", d.toString("utf8")));
proc.on("close", () => { proc.on("close", () => {
const stdout = Buffer.concat(chunks).toString("utf8").trim();
try { try {
resolve(JSON.parse(stdout.trim())); resolve(JSON.parse(stdout));
} catch (e) { } catch (e) {
reject(new Error("Python 返回了无效 JSON: " + stdout)); reject(new Error("Python 返回了无效 JSON: " + stdout));
} }
@@ -55,12 +62,19 @@ ipcMain.handle("template:parse", async (_, filePath) => {
return callPython({ action: "parse_template", file_path: filePath }); return callPython({ action: "parse_template", file_path: filePath });
}); });
let _mockDialogPath = null;
if (process.env.NODE_ENV !== "production") {
ipcMain.handle("test:mockDialog", (_, p) => { _mockDialogPath = p; });
}
ipcMain.handle("file:select", async (_, filters = []) => { ipcMain.handle("file:select", async (_, filters = []) => {
if (_mockDialogPath) { const p = _mockDialogPath; _mockDialogPath = null; return p; }
const result = await dialog.showOpenDialog({ filters, properties: ["openFile"] }); const result = await dialog.showOpenDialog({ filters, properties: ["openFile"] });
return result.canceled ? null : result.filePaths[0]; return result.canceled ? null : result.filePaths[0];
}); });
ipcMain.handle("file:selectDir", async () => { ipcMain.handle("file:selectDir", async () => {
if (_mockDialogPath) { const p = _mockDialogPath; _mockDialogPath = null; return p; }
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
return result.canceled ? null : result.filePaths[0]; return result.canceled ? null : result.filePaths[0];
}); });
+3
View File
@@ -14,4 +14,7 @@ contextBridge.exposeInMainWorld("api", {
ipcRenderer.invoke("file:copyToAppData", srcPath, templateId), ipcRenderer.invoke("file:copyToAppData", srcPath, templateId),
generate: (req) => ipcRenderer.invoke("generate:run", req), generate: (req) => ipcRenderer.invoke("generate:run", req),
// 仅测试环境使用:注入下一次对话框返回值
mockDialog: (p) => ipcRenderer.invoke("test:mockDialog", p),
}); });
+1183 -1
View File
File diff suppressed because it is too large Load Diff
+59 -9
View File
@@ -4,20 +4,70 @@
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"dev": "concurrently \"npm run dev:renderer\" \"wait-on http://localhost:5173 && electron .\"", "dev": "concurrently \"npm run dev:renderer\" \"wait-on http://localhost:5173 && NODE_ENV=development electron .\"",
"dev:renderer": "vite --config renderer/vite.config.mjs", "dev:renderer": "vite --config renderer/vite.config.mjs --port 5173 --strictPort",
"build": "vite build --config renderer/vite.config.mjs && electron-builder", "build:python": "cd python && .venv/bin/pyinstaller --onefile --name main --distpath dist --workpath build --specpath . --hidden-import xl_parser --hidden-import generator --collect-all openpyxl --collect-all et_xmlfile --collect-all PIL main.py",
"test": "cd /Users/mikivl/workspace/excel-batch-editor && python3 -m pytest tests/python/ -v" "build": "cd renderer && npx vite build --config vite.config.mjs && cd .. && electron-builder",
"dist": "npm run build:python && npm run build",
"test": "/Users/mikivl/workspace/excel-batch-editor/python/.venv/bin/pytest tests/python/ -v"
},
"build": {
"appId": "com.excelbatcheditor.app",
"productName": "Excel批量编辑器",
"files": [
"electron/**/*",
"renderer/dist/**/*",
"node_modules/**/*",
"package.json"
],
"asarUnpack": [
"node_modules/better-sqlite3/**/*"
],
"extraResources": [
{
"from": "python/dist/",
"to": "python",
"filter": ["main", "main.exe"]
}
],
"mac": {
"identity": null,
"hardenedRuntime": false,
"gatekeeperAssess": false,
"target": [
{ "target": "dmg", "arch": "arm64" }
],
"category": "public.app-category.productivity"
},
"dmg": {
"contents": [
{ "x": 130, "y": 150, "type": "file" },
{ "x": 410, "y": 150, "type": "link", "path": "/Applications" }
]
},
"win": {
"target": [
{ "target": "nsis", "arch": ["x64"] }
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^4.2.0",
"concurrently": "^8.2.2",
"electron": "^28.0.0", "electron": "^28.0.0",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"concurrently": "^8.2.2", "electron-rebuild": "^3.2.9",
"wait-on": "^7.2.0",
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.2.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0" "vite": "^5.0.0",
"wait-on": "^7.2.0"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
+10
View File
@@ -0,0 +1,10 @@
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./tests/e2e",
timeout: 30000,
reporter: "list",
use: {
headless: false,
},
});
+6 -1
View File
@@ -1,5 +1,10 @@
import sys import sys
import json import json
import io
# Windows 默认编码不是 UTF-8,强制设置
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8")
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
def main(): def main():
for line in sys.stdin: for line in sys.stdin:
@@ -10,7 +15,7 @@ def main():
req = json.loads(line) req = json.loads(line)
action = req.get("action") action = req.get("action")
if action == "parse_template": if action == "parse_template":
from parser import parse_template from xl_parser import parse_template
result = parse_template(req["file_path"]) result = parse_template(req["file_path"])
elif action == "generate": elif action == "generate":
from generator import generate from generator import generate
+49
View File
@@ -0,0 +1,49 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = []
binaries = []
hiddenimports = ['xl_parser', 'generator']
tmp_ret = collect_all('openpyxl')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('et_xmlfile')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('PIL')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['main.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='main',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+1
View File
@@ -4,6 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
base: "./",
build: { build: {
outDir: "dist", outDir: "dist",
emptyOutDir: true, emptyOutDir: true,
+116
View File
@@ -0,0 +1,116 @@
const { test, expect, _electron: electron } = require("@playwright/test");
const path = require("path");
const fs = require("fs");
const os = require("os");
const APP_ROOT = path.join(__dirname, "../..");
const FIXTURE_TEMPLATE = path.join(__dirname, "../fixtures/sample_template.xlsx");
const OUTPUT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ebe-test-"));
let app;
let page;
test.beforeAll(async () => {
app = await electron.launch({
args: [APP_ROOT],
// 用 test 模式:不是 production,mock IPC 注册;不是 development,加载 dist
env: { ...process.env, NODE_ENV: "test" },
});
page = await app.firstWindow();
page.on("console", (msg) => {
if (msg.type() === "error") console.error("[renderer]", msg.text());
});
await page.waitForSelector("button:has-text('+ 新建模板')", { timeout: 10000 });
});
test.afterAll(async () => {
await app.close();
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
});
test("T1: 显示模板列表页", async () => {
await expect(page.locator("button:has-text('+ 新建模板')")).toBeVisible();
});
test("T2: 新建模板", async () => {
await page.click("button:has-text('+ 新建模板')");
await page.waitForSelector("input[placeholder='如:月度报告模板']", { timeout: 5000 });
await page.fill("input[placeholder='如:月度报告模板']", "测试模板");
await page.fill("input[placeholder='如:财务部']", "测试分组");
// 注入下次对话框返回值,再点击选择文件
await page.evaluate((p) => window.api.mockDialog(p), FIXTURE_TEMPLATE);
await page.click("button:has-text('选择文件')");
// 等待文件路径填入
await page.waitForFunction(
() => Array.from(document.querySelectorAll("input[readonly]")).some(i => i.value.includes(".xlsx")),
{ timeout: 8000 }
);
// 等待 parse 完成
await page.waitForTimeout(2000);
await page.click("button:has-text('保存模板')");
await page.waitForSelector("span:has-text('测试模板')", { timeout: 8000 });
await expect(page.locator("span:has-text('测试模板')")).toBeVisible();
});
test("T3: 模板列表显示详情", async () => {
await page.click("span:has-text('测试模板')");
await expect(page.locator("h1")).toContainText("测试模板", { timeout: 3000 });
await expect(page.locator("button:has-text('批量生成')")).toBeVisible();
});
test("T4: 批量生成 - 手动输入", async () => {
await page.click("button:has-text('批量生成')");
await page.waitForSelector("h1:has-text('批量生成')", { timeout: 5000 });
await page.click("label:has-text('手动填写')");
await page.waitForTimeout(500);
const inputs = page.locator("table tbody tr:first-child input");
const count = await inputs.count();
for (let i = 0; i < count; i++) {
await inputs.nth(i).fill(`值${i + 1}`);
}
// 注入输出目录
await page.evaluate((dir) => window.api.mockDialog(dir), OUTPUT_DIR);
await page.click("button:has-text('选择目录')");
await page.waitForFunction(
() => Array.from(document.querySelectorAll("input[readonly]")).some(i => i.value.length > 0 && !i.value.includes(".xlsx")),
{ timeout: 5000 }
);
await page.click("button:has-text('开始生成')");
await expect(page.locator("text=生成结果")).toBeVisible({ timeout: 20000 });
const files = fs.readdirSync(OUTPUT_DIR);
console.log("生成的文件:", files);
expect(files.length).toBeGreaterThan(0);
});
test("T5: 编辑模板名称", async () => {
await page.click("button:has-text('← 返回模板列表')");
await page.waitForSelector("button:has-text('+ 新建模板')", { timeout: 5000 });
await page.click("span:has-text('测试模板')");
await page.waitForSelector("button:has-text('编辑字段映射')", { timeout: 3000 });
await page.click("button:has-text('编辑字段映射')");
await page.waitForSelector("input[placeholder='如:月度报告模板']", { timeout: 5000 });
await page.fill("input[placeholder='如:月度报告模板']", "测试模板(已修改)");
await page.click("button:has-text('保存模板')");
await page.waitForSelector("span:has-text('测试模板(已修改)')", { timeout: 5000 });
await expect(page.locator("span:has-text('测试模板(已修改)')")).toBeVisible();
});
test("T6: 删除模板", async () => {
page.once("dialog", (d) => d.accept());
await page.locator("div.flex.items-center.justify-between button:has-text('删除')").first().click();
await page.waitForTimeout(800);
await expect(page.locator("span:has-text('测试模板(已修改)')")).not.toBeVisible({ timeout: 3000 });
});
Binary file not shown.
+7 -8
View File
@@ -1,7 +1,7 @@
import sys, os import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../python")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../python"))
from parser import parse_template from xl_parser import parse_template
FIXTURE = os.path.join(os.path.dirname(__file__), "../fixtures/sample_template.xlsx") FIXTURE = os.path.join(os.path.dirname(__file__), "../fixtures/sample_template.xlsx")
@@ -25,13 +25,12 @@ def test_placeholder_has_cell_info():
result = parse_template(FIXTURE) result = parse_template(FIXTURE)
biaohao = next(p for p in result["placeholders"] if p["name"] == "编号") biaohao = next(p for p in result["placeholders"] if p["name"] == "编号")
assert biaohao["sheet"] == "Sheet1" assert biaohao["sheet"] == "Sheet1"
assert biaohao["cell"] == "B3" assert biaohao["cell"] == "A2"
def test_multiple_placeholders_in_one_cell(): def test_multiple_placeholders_detected():
result = parse_template(FIXTURE) result = parse_template(FIXTURE)
names = [p["name"] for p in result["placeholders"]] names = [p["name"] for p in result["placeholders"]]
assert "客户名" in names assert "编号" in names
assert "编号" in names # 已在其他地方存在,但也在 E9 中 assert "姓名" in names
# 验证 E9 中的两个占位符都被检测到 assert "部门" in names
e9_placeholders = [p for p in result["placeholders"] if p["cell"] == "E9"] assert "日期" in names
assert len(e9_placeholders) == 2