Compare commits
18
Commits
aee1c6bb69
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c649b8c7c | ||
|
|
6b2c87dc18 | ||
|
|
a4d1218d21 | ||
|
|
df2488c653 | ||
|
|
ec105b3828 | ||
|
|
cb2c186f7f | ||
|
|
70a56ede36 | ||
|
|
9738d2cd67 | ||
|
|
fcf3d5c7c2 | ||
|
|
696fd54809 | ||
|
|
c33b048217 | ||
|
|
a01c09dc9f | ||
|
|
a37f17b3b6 | ||
|
|
58f639c113 | ||
|
|
37951760e3 | ||
|
|
8fa85966e0 | ||
|
|
b3be6f5f03 | ||
|
|
1c164ea3d9 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -39,6 +39,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Electron
|
# Electron
|
||||||
dist/
|
dist/
|
||||||
|
renderer/dist/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Excel 批量编辑器
|
||||||
|
|
||||||
|
跨平台桌面应用(Mac/Windows),支持上传 Excel 模板、标记可替换字段,批量生成填充好的输出文件。
|
||||||
|
|
||||||
|
## 开发环境
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
- Node.js 18+
|
||||||
|
- Python 3.9+(或 3.11+ 更佳)
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cd /path/to/excel-batch-editor && source python/.venv/bin/activate 2>/dev/null || python3 -m venv python/.venv && source python/.venv/bin/activate && pip install -r python/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
或分步执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Node.js 依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Python 依赖
|
||||||
|
cd excel-batch-editor
|
||||||
|
python3 -m venv python/.venv
|
||||||
|
source python/.venv/bin/activate # Windows: python\.venv\Scripts\activate
|
||||||
|
pip install -r python/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发模式启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHON_PATH=$(which python3) # 或使用 venv:export PYTHON_PATH=$(pwd)/python/.venv/bin/python3
|
||||||
|
NODE_ENV=development npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
应用将在 `http://localhost:5173` 启动渲染进程,Electron 窗口自动打开。
|
||||||
|
|
||||||
|
### 运行 Python 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source python/.venv/bin/activate
|
||||||
|
pytest tests/python/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
excel-batch-editor/
|
||||||
|
├── electron/ # Electron 主进程
|
||||||
|
│ ├── main.js # 入口
|
||||||
|
│ ├── preload.js # IPC 桥接
|
||||||
|
│ ├── ipc/ # IPC handler
|
||||||
|
│ └── db/ # SQLite 操作
|
||||||
|
├── renderer/ # React 前端(Vite)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/ # 页面组件
|
||||||
|
│ └── components/ # 通用组件
|
||||||
|
├── python/ # Python Excel 处理
|
||||||
|
│ ├── main.py # stdin/stdout 入口
|
||||||
|
│ ├── parser.py # 模板解析
|
||||||
|
│ └── generator.py # 批量生成
|
||||||
|
└── tests/ # 测试
|
||||||
|
└── python/ # Python 单元测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **桌面壳**:Electron 28(跨平台)
|
||||||
|
- **UI**:React 18 + Vite + Tailwind CSS
|
||||||
|
- **Excel 处理**:Python 3 + openpyxl
|
||||||
|
- **本地存储**:SQLite(better-sqlite3)
|
||||||
@@ -19,11 +19,11 @@ function saveTemplate({ name, grp = "", file_path, fields = [] }) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTemplate(id, { name, grp, fields }) {
|
function updateTemplate(id, { name, grp, file_path, fields }) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
if (name !== undefined || grp !== undefined) {
|
if (name !== undefined || grp !== undefined || file_path !== undefined) {
|
||||||
db.prepare("UPDATE templates SET name = COALESCE(?, name), grp = COALESCE(?, grp), updated_at = ? WHERE id = ?")
|
db.prepare("UPDATE templates SET name = COALESCE(?, name), grp = COALESCE(?, grp), file_path = COALESCE(?, file_path), updated_at = ? WHERE id = ?")
|
||||||
.run(name ?? null, grp ?? null, now, id);
|
.run(name ?? null, grp ?? null, file_path ?? null, now, id);
|
||||||
}
|
}
|
||||||
if (fields !== undefined) {
|
if (fields !== undefined) {
|
||||||
db.prepare("DELETE FROM fields WHERE template_id = ?").run(id);
|
db.prepare("DELETE FROM fields WHERE template_id = ?").run(id);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const { ipcMain } = require("electron");
|
||||||
|
const { callPython } = require("./templateIpc");
|
||||||
|
|
||||||
|
ipcMain.handle("generate:run", async (_, req) => {
|
||||||
|
return callPython({ action: "generate", ...req });
|
||||||
|
});
|
||||||
@@ -8,26 +8,34 @@ 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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
proc.on("error", (e) => reject(new Error("Python 进程启动失败: " + e.message)));
|
||||||
proc.stdin.write(JSON.stringify(payload) + "\n");
|
proc.stdin.write(JSON.stringify(payload) + "\n");
|
||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
});
|
});
|
||||||
@@ -54,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];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+1183
-1
File diff suppressed because it is too large
Load Diff
+59
-9
@@ -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.js",
|
"dev:renderer": "vite --config renderer/vite.config.mjs --port 5173 --strictPort",
|
||||||
"build": "vite build --config renderer/vite.config.js && 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",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const { defineConfig } = require("@playwright/test");
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 30000,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
headless: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
+6
-1
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Excel 批量编辑器</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MemoryRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import TemplateList from "./pages/TemplateList";
|
||||||
|
import TemplateConfig from "./pages/TemplateConfig";
|
||||||
|
import Generate from "./pages/Generate";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<MemoryRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/templates" replace />} />
|
||||||
|
<Route path="/templates" element={<TemplateList />} />
|
||||||
|
<Route path="/templates/new" element={<TemplateConfig />} />
|
||||||
|
<Route path="/templates/:id/edit" element={<TemplateConfig />} />
|
||||||
|
<Route path="/generate" element={<Generate />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const FIELD_TYPES = [
|
||||||
|
{ value: "text", label: "文本" },
|
||||||
|
{ value: "image", label: "图片" },
|
||||||
|
{ value: "table_range", label: "批量填充区域" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FieldMappingEditor({ fields, onChange }) {
|
||||||
|
function addField() {
|
||||||
|
onChange([...fields, { name: "", type: "text", sheet: "Sheet1", cell: "" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(index, patch) {
|
||||||
|
const next = fields.map((f, i) => (i === index ? { ...f, ...patch } : f));
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(index) {
|
||||||
|
onChange(fields.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-5 gap-2 text-xs text-gray-400 font-medium px-1">
|
||||||
|
<span>字段名</span>
|
||||||
|
<span>类型</span>
|
||||||
|
<span>Sheet</span>
|
||||||
|
<span>单元格</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-5 gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={f.name}
|
||||||
|
onChange={(e) => updateField(i, { name: e.target.value })}
|
||||||
|
placeholder="如:编号"
|
||||||
|
className="border rounded px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={f.type}
|
||||||
|
onChange={(e) => updateField(i, { type: e.target.value })}
|
||||||
|
className="border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{FIELD_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={f.sheet}
|
||||||
|
onChange={(e) => updateField(i, { sheet: e.target.value })}
|
||||||
|
placeholder="Sheet1"
|
||||||
|
className="border rounded px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={f.cell}
|
||||||
|
onChange={(e) => updateField(i, { cell: e.target.value })}
|
||||||
|
placeholder="如:B3"
|
||||||
|
className="border rounded px-2 py-1 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeField(i)}
|
||||||
|
className="text-gray-300 hover:text-red-500 text-sm"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={addField}
|
||||||
|
className="text-blue-600 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
+ 添加字段
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function ProgressPanel({ results, outputDir, onOpenDir }) {
|
||||||
|
if (!results) return null;
|
||||||
|
|
||||||
|
const success = results.filter((r) => r.status === "success").length;
|
||||||
|
const failed = results.filter((r) => r.status === "error").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<span className="text-green-600 font-medium">成功:{success}</span>
|
||||||
|
{failed > 0 && <span className="text-red-500 font-medium">失败:{failed}</span>}
|
||||||
|
</div>
|
||||||
|
{outputDir && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenDir}
|
||||||
|
className="text-blue-600 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
打开输出文件夹 →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{failed > 0 && (
|
||||||
|
<div className="border border-red-100 rounded bg-red-50 p-3 space-y-1">
|
||||||
|
<p className="text-xs font-medium text-red-600">失败详情:</p>
|
||||||
|
{results
|
||||||
|
.filter((r) => r.status === "error")
|
||||||
|
.map((r) => (
|
||||||
|
<p key={r.row} className="text-xs text-red-500">
|
||||||
|
第 {r.row + 1} 行:{r.error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||||
|
{results.map((r) => (
|
||||||
|
<div key={r.row} className={`text-xs px-2 py-1 rounded flex justify-between ${r.status === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-600"}`}>
|
||||||
|
<span>{r.status === "success" ? "✓" : "✗"} 第 {r.row + 1} 行</span>
|
||||||
|
<span className="truncate ml-2 text-gray-400">{r.file?.split("/").pop() || r.file?.split("\\").pop()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import ProgressPanel from "../components/ProgressPanel";
|
||||||
|
|
||||||
|
export default function Generate() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const preselectedId = location.state?.templateId;
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [selectedId, setSelectedId] = useState(preselectedId || "");
|
||||||
|
const [dataSource, setDataSource] = useState("file");
|
||||||
|
const [dataFilePath, setDataFilePath] = useState("");
|
||||||
|
const [manualRows, setManualRows] = useState([{}]);
|
||||||
|
const [outputDir, setOutputDir] = useState("");
|
||||||
|
const [filenamePattern, setFilenamePattern] = useState("输出_{{编号}}.xlsx");
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [results, setResults] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.listTemplates().then(setTemplates);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedTemplate = templates.find((t) => t.id === selectedId);
|
||||||
|
const fields = selectedTemplate?.fields || [];
|
||||||
|
|
||||||
|
async function handleSelectDataFile() {
|
||||||
|
const path = await window.api.selectFile([
|
||||||
|
{ name: "Excel 文件", extensions: ["xlsx"] },
|
||||||
|
]);
|
||||||
|
if (path) setDataFilePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectOutputDir() {
|
||||||
|
const dir = await window.api.selectDirectory();
|
||||||
|
if (dir) setOutputDir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateManualRow(rowIdx, fieldName, value) {
|
||||||
|
setManualRows((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[rowIdx] = { ...next[rowIdx], [fieldName]: value };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addManualRow() {
|
||||||
|
setManualRows((prev) => [...prev, {}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeManualRow(idx) {
|
||||||
|
setManualRows((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (!selectedId) return alert("请选择模板");
|
||||||
|
if (!outputDir) return alert("请选择输出目录");
|
||||||
|
if (dataSource === "file" && !dataFilePath) return alert("请选择数据源 Excel 文件");
|
||||||
|
|
||||||
|
setGenerating(true);
|
||||||
|
setResults(null);
|
||||||
|
try {
|
||||||
|
const result = await window.api.generate({
|
||||||
|
template_path: selectedTemplate.file_path,
|
||||||
|
fields: fields,
|
||||||
|
rows: dataSource === "manual" ? manualRows : null,
|
||||||
|
data_file_path: dataSource === "file" ? dataFilePath : null,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_pattern: filenamePattern,
|
||||||
|
});
|
||||||
|
setResults(result.results);
|
||||||
|
} catch (err) {
|
||||||
|
alert("生成失败:" + (err.message || String(err)));
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-8">
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">批量生成</h1>
|
||||||
|
<button onClick={() => navigate("/templates")} className="text-gray-400 hover:text-gray-600 text-sm">
|
||||||
|
← 返回模板列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">选择模板</label>
|
||||||
|
<select
|
||||||
|
value={selectedId}
|
||||||
|
onChange={(e) => setSelectedId(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">请选择...</option>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">数据来源</label>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" value="file" checked={dataSource === "file"} onChange={() => setDataSource("file")} />
|
||||||
|
上传 Excel 数据源
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" value="manual" checked={dataSource === "manual"} onChange={() => setDataSource("manual")} />
|
||||||
|
手动填写
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dataSource === "file" ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input readOnly value={dataFilePath} className="flex-1 border rounded px-3 py-2 text-sm bg-gray-50" placeholder="选择数据源 Excel 文件..." />
|
||||||
|
<button onClick={handleSelectDataFile} className="border rounded px-4 py-2 text-sm hover:bg-gray-100">选择文件</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="text-sm border-collapse w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<th key={f.id} className="border px-2 py-1 bg-gray-50 text-left text-xs font-medium">{f.name}</th>
|
||||||
|
))}
|
||||||
|
<th className="border px-2 py-1 bg-gray-50 w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{manualRows.map((row, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<td key={f.id} className="border px-1 py-1">
|
||||||
|
<input
|
||||||
|
value={row[f.name] || ""}
|
||||||
|
onChange={(e) => updateManualRow(idx, f.name, e.target.value)}
|
||||||
|
className="w-full px-1 py-0.5 text-sm outline-none"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="border px-2 py-1 text-center">
|
||||||
|
<button onClick={() => removeManualRow(idx)} className="text-gray-300 hover:text-red-500 text-xs">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button onClick={addManualRow} className="text-blue-600 text-xs mt-2 hover:underline">+ 添加行</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">输出目录</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input readOnly value={outputDir} className="flex-1 border rounded px-3 py-2 text-sm bg-gray-50" placeholder="选择输出文件夹..." />
|
||||||
|
<button onClick={handleSelectOutputDir} className="border rounded px-4 py-2 text-sm hover:bg-gray-100">选择目录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">输出文件命名规则</label>
|
||||||
|
<input
|
||||||
|
value={filenamePattern}
|
||||||
|
onChange={(e) => setFilenamePattern(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm font-mono"
|
||||||
|
placeholder="如:{{编号}}_报告.xlsx"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">支持使用 {"{{字段名}}"} 作为变量</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating || !selectedId || !outputDir}
|
||||||
|
className="w-full bg-blue-600 text-white rounded px-4 py-2 text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generating ? "生成中..." : "开始生成"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results && (
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h2 className="font-medium mb-4">生成结果</h2>
|
||||||
|
<ProgressPanel
|
||||||
|
results={results}
|
||||||
|
outputDir={outputDir}
|
||||||
|
onOpenDir={() => window.api.openDirectory(outputDir)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import FieldMappingEditor from "../components/FieldMappingEditor";
|
||||||
|
|
||||||
|
export default function TemplateConfig() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [grp, setGrp] = useState("");
|
||||||
|
const [filePath, setFilePath] = useState("");
|
||||||
|
const [originalFilePath, setOriginalFilePath] = useState("");
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [autoDetected, setAutoDetected] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) {
|
||||||
|
window.api.listTemplates().then((templates) => {
|
||||||
|
const t = templates.find((t) => t.id === id);
|
||||||
|
if (t) {
|
||||||
|
setName(t.name);
|
||||||
|
setGrp(t.grp || "");
|
||||||
|
setFilePath(t.file_path);
|
||||||
|
setOriginalFilePath(t.file_path);
|
||||||
|
setFields(t.fields || []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
async function handleSelectFile() {
|
||||||
|
const path = await window.api.selectFile([
|
||||||
|
{ name: "Excel 文件", extensions: ["xlsx"] },
|
||||||
|
]);
|
||||||
|
if (!path) return;
|
||||||
|
setFilePath(path);
|
||||||
|
const result = await window.api.parseTemplate(path);
|
||||||
|
if (result.placeholders?.length > 0) {
|
||||||
|
setAutoDetected(result.placeholders);
|
||||||
|
const detected = result.placeholders.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
type: "text",
|
||||||
|
sheet: p.sheet,
|
||||||
|
cell: p.cell,
|
||||||
|
}));
|
||||||
|
setFields((prev) => {
|
||||||
|
const existing = prev.map((f) => f.name);
|
||||||
|
const newOnes = detected.filter((d) => !existing.includes(d.name));
|
||||||
|
return [...prev, ...newOnes];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!name.trim() || !filePath) return alert("请填写模板名称并选择文件");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (!isEdit) {
|
||||||
|
const tempId = Date.now().toString();
|
||||||
|
const storedPath = await window.api.copyFileToAppData(filePath, tempId);
|
||||||
|
await window.api.saveTemplate({ name, grp, file_path: storedPath, fields });
|
||||||
|
} else {
|
||||||
|
let storedPath;
|
||||||
|
if (filePath !== originalFilePath) {
|
||||||
|
storedPath = await window.api.copyFileToAppData(filePath, id);
|
||||||
|
}
|
||||||
|
await window.api.updateTemplate(id, { name, grp, file_path: storedPath, fields });
|
||||||
|
}
|
||||||
|
navigate("/templates");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-8">
|
||||||
|
<div className="max-w-2xl mx-auto bg-white rounded-lg border p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">{isEdit ? "编辑模板" : "新建模板"}</h1>
|
||||||
|
<button onClick={() => navigate("/templates")} className="text-gray-400 hover:text-gray-600 text-sm">
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">模板名称</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
placeholder="如:月度报告模板"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">分组(可选)</label>
|
||||||
|
<input
|
||||||
|
value={grp}
|
||||||
|
onChange={(e) => setGrp(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
placeholder="如:财务部"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Excel 模板文件</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={filePath}
|
||||||
|
className="flex-1 border rounded px-3 py-2 text-sm bg-gray-50"
|
||||||
|
placeholder="点击右侧按钮选择文件..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectFile}
|
||||||
|
className="border rounded px-4 py-2 text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
选择文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{autoDetected.length > 0 && (
|
||||||
|
<p className="text-xs text-green-600 mt-1">
|
||||||
|
已自动识别 {autoDetected.length} 个占位符
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">字段映射</label>
|
||||||
|
<FieldMappingEditor fields={fields} onChange={setFields} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/templates")}
|
||||||
|
className="border rounded px-4 py-2 text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-blue-600 text-white rounded px-4 py-2 text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "保存中..." : "保存模板"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function TemplateList() {
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.listTemplates().then(setTemplates);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (!confirm("确认删除此模板?")) return;
|
||||||
|
await window.api.deleteTemplate(id);
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
if (selected?.id === id) setSelected(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = [...new Set(templates.map((t) => t.grp || "未分组"))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-50">
|
||||||
|
{/* 左侧模板列表 */}
|
||||||
|
<aside className="w-72 bg-white border-r flex flex-col">
|
||||||
|
<div className="p-4 border-b flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/templates/new")}
|
||||||
|
className="flex-1 bg-blue-600 text-white rounded px-3 py-2 text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ 新建模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{groups.map((grp) => (
|
||||||
|
<div key={grp} className="mb-4">
|
||||||
|
<div className="text-xs text-gray-400 font-semibold px-2 py-1 uppercase">{grp}</div>
|
||||||
|
{templates
|
||||||
|
.filter((t) => (t.grp || "未分组") === grp)
|
||||||
|
.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setSelected(t)}
|
||||||
|
className={`flex items-center justify-between rounded px-3 py-2 cursor-pointer text-sm ${
|
||||||
|
selected?.id === t.id ? "bg-blue-50 text-blue-700" : "hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{t.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
|
||||||
|
className="text-gray-300 hover:text-red-500 ml-2 text-xs"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右侧详情 */}
|
||||||
|
<main className="flex-1 p-8">
|
||||||
|
{selected ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">{selected.name}</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/templates/${selected.id}/edit`)}
|
||||||
|
className="border rounded px-4 py-2 text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
编辑字段映射
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/generate", { state: { templateId: selected.id } })}
|
||||||
|
className="bg-blue-600 text-white rounded px-4 py-2 text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
批量生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg border p-6 space-y-3 text-sm text-gray-600">
|
||||||
|
<p><span className="font-medium text-gray-800">分组:</span>{selected.grp || "未分组"}</p>
|
||||||
|
<p><span className="font-medium text-gray-800">字段数量:</span>{selected.fields?.length ?? 0}</p>
|
||||||
|
<p><span className="font-medium text-gray-800">创建时间:</span>{new Date(selected.created_at).toLocaleString("zh-CN")}</p>
|
||||||
|
<p><span className="font-medium text-gray-800">最后修改:</span>{new Date(selected.updated_at).toLocaleString("zh-CN")}</p>
|
||||||
|
{selected.fields?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-800 mb-2">字段列表:</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selected.fields.map((f) => (
|
||||||
|
<div key={f.id} className="flex gap-4 bg-gray-50 rounded px-3 py-1">
|
||||||
|
<span className="font-mono text-blue-600">{"{{"}{f.name}{"}}"}</span>
|
||||||
|
<span className="text-gray-400">{f.type}</span>
|
||||||
|
<span>{f.sheet} · {f.cell}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center text-gray-400">
|
||||||
|
<p>选择一个模板,或新建模板</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
base: "./",
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
Vendored
BIN
Binary file not shown.
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user