Compare commits

...

10 Commits

Author SHA1 Message Date
MikiVL
fcf3d5c7c2 fix: 生成页错误处理,编辑模式支持更新模板文件 2026-05-05 14:03:19 +08:00
MikiVL
696fd54809 docs: 添加 README 和 .gitignore 2026-05-05 13:54:38 +08:00
MikiVL
c33b048217 fix: 生成前校验数据源文件路径 2026-05-05 13:53:50 +08:00
MikiVL
a01c09dc9f feat: 批量生成页(文件/手动输入、进度展示) 2026-05-05 13:47:42 +08:00
MikiVL
a37f17b3b6 feat: 模板配置页(上传文件、自动识别占位符、字段映射) 2026-05-05 13:46:17 +08:00
MikiVL
58f639c113 feat: 字段映射编辑组件 2026-05-05 13:45:26 +08:00
MikiVL
37951760e3 feat: 模板管理页(列表、分组、详情) 2026-05-05 13:44:58 +08:00
MikiVL
8fa85966e0 chore: React + Vite + Tailwind CSS 脚手架
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:43:20 +08:00
MikiVL
b3be6f5f03 feat: 批量生成 IPC handler 2026-05-05 13:37:15 +08:00
MikiVL
1c164ea3d9 fix: 为 callPython 添加 spawn 错误处理,防止进程启动失败时永久挂起 2026-05-05 13:36:44 +08:00
16 changed files with 724 additions and 6 deletions

1
.gitignore vendored
View File

@ -39,6 +39,7 @@ Thumbs.db
# Electron # Electron
dist/ dist/
renderer/dist/
out/ out/
# Database # Database

73
README.md Normal file
View File

@ -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) # 或使用 venvexport 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
- **本地存储**SQLitebetter-sqlite3

View File

@ -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);

View File

@ -0,0 +1,6 @@
const { ipcMain } = require("electron");
const { callPython } = require("./templateIpc");
ipcMain.handle("generate:run", async (_, req) => {
return callPython({ action: "generate", ...req });
});

View File

@ -28,6 +28,7 @@ function callPython(payload) {
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();
}); });

View File

@ -5,8 +5,8 @@
"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 && electron .\"",
"dev:renderer": "vite --config renderer/vite.config.js", "dev:renderer": "vite --config renderer/vite.config.mjs",
"build": "vite build --config renderer/vite.config.js && electron-builder", "build": "vite build --config renderer/vite.config.mjs && electron-builder",
"test": "cd /Users/mikivl/workspace/excel-batch-editor && python3 -m pytest tests/python/ -v" "test": "cd /Users/mikivl/workspace/excel-batch-editor && python3 -m pytest tests/python/ -v"
}, },
"devDependencies": { "devDependencies": {

12
renderer/index.html Normal file
View File

@ -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>

19
renderer/src/App.jsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

1
renderer/src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

6
renderer/src/main.jsx Normal file
View File

@ -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 />);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

14
renderer/vite.config.mjs Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: "dist",
emptyOutDir: true,
},
server: {
port: 5173,
},
});