CSV 导入导出
表格数据要出去(给 Excel、给同事)或成批进来(历史数据、别的系统的导出)时的标准形状:导出把当前行按 RFC 4180 序列化成 CSV 下载;导入先解析预览、逐行校验,确认后只把合法行交给批量创建。核心是三个纯粹的工具函数:toCsv / downloadCsv / parseCsv。
试这几下:
- 切换状态筛选再点导出 CSV —— 导出的永远是筛选后的行;展开「预览 CSV 文本」不用下载也能看到产物
- 点导入 CSV → 填入示例 CSV —— 示例里埋了三个转义坑(引号内逗号、翻倍引号、引号内换行)和几条坏行:合法行绿、非法行红并给出行级错误(ID 非数字、状态非枚举、缺值)
- 点导入 n 行 —— 只入合法行,重复 id(表里已有的 3 号)跳过,toast 分类报数
- 也可以选本地 .csv 文件或直接粘贴 CSV 文本,预览随输入实时刷新;导出的 CSV 粘回去能无损往返
规矩
- 转义交给
toCsv,别手拼字符串:字段含逗号/引号/CR/LF 才裹双引号,内嵌引号翻倍(RFC 4180);parseCsv反向同样处理引号内的逗号与换行,往返不丢字 downloadCsv只在浏览器跑:函数内守卫document,服务端调用是 no-op;parseCsv是纯函数,服务端安全- 导入只预览,落库前必须校验:解析出的每行过资源的 schema(demo 用表头映射 + 行级规则代替 Zod),确认后只提交合法行,成败都用 toast 报数。demo 写内存;真实版把合法行交给 bulk-create mutation(server fn 里先
requireUser()) - 导出喂真实数据源:demo 序列化内存里筛选后的行,真实版从
Repository.list()拿全部匹配行再downloadCsv
蓝本:
add-export-import.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-export-import.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:CSV 导入导出</title>
<!--
demo-export-import.html —— 「CSV 导入导出」形状的最小可玩实现(蓝本:open-dashboard 的 csv.ts 契约 + add-export-import)。
复现的不变量:
- toCsv 按 RFC 4180 转义:字段含逗号/引号/CR/LF 才裹引号,内嵌引号翻倍;不手拼字符串
- downloadCsv 只在浏览器可用(守卫 document);parseCsv 是纯函数,正确处理引号内的逗号与换行
- 导入只预览不直接落库:表头映射 + 逐行校验(合法绿/非法红 + 行级错误),确认后只入合法行,重复 id 跳过并报告
- 导出作用于当前(筛选后)的行;导入导出的结果都用 toast 报数
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
details { margin-bottom: 10px; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; user-select: none; }
.csvbox { margin: 8px 0 0; padding: 10px 12px; background: var(--card); border: 1px solid var(--border);
border-radius: 10px; font: 12px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap; word-break: break-all; }
.modal.wide { max-width: 680px; max-height: 86vh; overflow: auto; }
textarea.csv { font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; min-height: 92px; resize: vertical; }
.preview-wrap { max-height: 250px; overflow: auto; border-radius: 10px; }
.row-ok td { background: color-mix(in srgb, var(--chip-on-fg) 8%, var(--card)); }
.row-dup td { background: color-mix(in srgb, var(--chip-warn-fg) 10%, var(--card)); }
.row-bad td { background: color-mix(in srgb, var(--danger) 10%, var(--card)); }
.vmsg { font-size: 12px; white-space: normal; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect } = React;
const html = htm.bind(React.createElement);
const STATUSES = ["活跃", "邀请中", "停用"];
const CHIP = { "活跃": "chip-on", "邀请中": "chip-warn", "停用": "chip-off" };
const SEED = [
{ id: 1, name: "陈禾", email: "chenhe@corp.cn", role: "负责人", status: "活跃" },
{ id: 2, name: "李慕白", email: "mubai@corp.cn", role: "管理员", status: "活跃" },
{ id: 3, name: "王小满", email: "xiaoman@corp.cn", role: "工程师", status: "邀请中" },
{ id: 4, name: "赵青", email: "zhaoqing@corp.cn", role: "分析师", status: "活跃" },
{ id: 5, name: "周芷", email: "zhouzhi@corp.cn", role: "工程师", status: "停用" },
];
// 导出列投影:表头 + 取值器(蓝本的 CsvColumn<T>;真实版通常照抄资源的 columns.tsx)
const EXPORT_COLUMNS = [
{ header: "ID", accessor: r => r.id },
{ header: "姓名", accessor: r => r.name },
{ header: "邮箱", accessor: r => r.email },
{ header: "角色", accessor: r => r.role },
{ header: "状态", accessor: r => r.status },
];
const HEADERS = EXPORT_COLUMNS.map(c => c.header);
const REQUIRED = ["ID", "姓名", "邮箱", "状态"]; // 角色可空
// 内置示例:埋了引号内逗号、翻倍引号、引号内换行三个转义坑,外加坏行与重复 id
const SAMPLE_CSV = [
'ID,姓名,邮箱,角色,状态',
'21,"钱通,渠道推荐",qiantong@corp.cn,运营,活跃',
'22,"孙""大圣""邈",sunmiao@corp.cn,工程师,邀请中',
'23,"周\n行文",zhouxw@corp.cn,分析师,活跃',
'24,吴用,wuyong@corp.cn,工程师,在职',
'x9,林错,lincuo@corp.cn,运营,活跃',
'3,王小满,chongfu@corp.cn,工程师,活跃',
'26,郑缺,zhengque@corp.cn,分析师,',
].join("\n");
/* ---------- CSV 工具:蓝本 csv.ts 的逐行移植(toCsv/parseCsv 是纯函数) ---------- */
// RFC 4180:含逗号/引号/CR/LF 的字段裹双引号,内嵌引号翻倍
function escapeField(value) {
const str = value == null ? "" : String(value);
if (/[",\r\n]/.test(str)) return '"' + str.replaceAll('"', '""') + '"';
return str;
}
function toCsv(rows, columns) {
const lines = [columns.map(c => escapeField(c.header)).join(",")];
for (const row of rows) {
lines.push(columns.map(c => escapeField(c.accessor(row))).join(","));
}
return lines.join("\r\n");
}
// 仅浏览器可用:守卫 document,服务端调用是 no-op(蓝本不变量)
function downloadCsv(filename, csv) {
if (typeof document === "undefined") return;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
// 把 CSV 文本切成记录数组;引号内的逗号、换行、翻倍引号都按 RFC 4180 处理
function parseRecords(text) {
const records = [];
let field = "";
let record = [];
let inQuotes = false;
let started = false;
const pushField = () => { record.push(field); field = ""; };
const pushRecord = () => { pushField(); records.push(record); record = []; started = false; };
for (let i = 0; i < text.length; i++) {
const char = text[i];
started = true;
if (inQuotes) {
if (char === '"') {
if (text[i + 1] === '"') { field += '"'; i++; }
else inQuotes = false;
} else {
field += char;
}
continue;
}
if (char === '"') inQuotes = true;
else if (char === ",") pushField();
else if (char === "\r") { if (text[i + 1] === "\n") i++; pushRecord(); }
else if (char === "\n") pushRecord();
else field += char;
}
if (started || field !== "" || record.length > 0) pushRecord();
return records.filter(rec => !(rec.length === 1 && rec[0] === ""));
}
// 第一行是表头;数据行按表头映射成对象(短行补 "")
function parseCsv(text) {
const records = parseRecords(text);
if (records.length === 0) return { headers: [], rows: [] };
const headers = records[0];
const rows = records.slice(1).map(record => {
const row = {};
headers.forEach((header, index) => { row[header] = record[index] ?? ""; });
return row;
});
return { headers, rows };
}
/* ---------- 导入校验:表头映射 + 行级规则(真实版换成资源的 Zod schema) ---------- */
function validateRows(parsed, existingIds) {
const seen = new Set(existingIds);
return parsed.rows.map((raw, i) => {
const get = h => (parsed.headers.includes(h) ? (raw[h] || "").trim() : null);
const errors = [];
for (const h of REQUIRED) if (get(h) === null) errors.push(`缺少「${h}」列`);
const idText = get("ID");
let id = null;
if (idText === "") errors.push("ID 为空");
else if (idText !== null && !/^\d+$/.test(idText)) errors.push(`ID「${idText}」不是数字`);
else if (idText !== null) id = Number(idText);
if (get("姓名") === "") errors.push("姓名为空");
if (get("邮箱") === "") errors.push("邮箱为空");
const status = get("状态");
if (status === "") errors.push("状态为空");
else if (status !== null && !STATUSES.includes(status))
errors.push(`状态「${status}」不在枚举(${STATUSES.join("/")})`);
let state = "ok", msg = "";
if (errors.length) { state = "bad"; msg = errors.join(";"); }
else if (seen.has(id)) { state = "dup"; msg = `id ${id} 已存在,将跳过`; }
else seen.add(id);
return {
key: i, raw, state, msg,
row: { id, name: get("姓名") || "", email: get("邮箱") || "", role: get("角色") || "", status: status || "" },
};
});
}
function ImportModal({ existingIds, onImport, onClose }) {
const [text, setText] = useState("");
const checked = useMemo(
() => (text.trim() ? validateRows(parseCsv(text), existingIds) : null),
[text, existingIds],
);
const okRows = checked ? checked.filter(r => r.state === "ok") : [];
const dupN = checked ? checked.filter(r => r.state === "dup").length : 0;
const badN = checked ? checked.filter(r => r.state === "bad").length : 0;
const pickFile = e => {
const file = e.target.files && e.target.files[0];
if (!file) return;
file.text().then(t => setText(t));
e.target.value = ""; // 允许再选同一个文件
};
return html`
<div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal wide">
<h2>导入 CSV</h2>
<p class="sub" style=${{ margin: 0 }}>
第一行是表头(需含 ${REQUIRED.join("、")},多余列忽略);先解析预览、逐行校验,确认后只导入合法行。
</p>
<div class="toolbar" style=${{ margin: 0 }}>
<input type="file" accept=".csv,text/csv" aria-label="选择 CSV 文件" class="grow" onChange=${pickFile} />
<button class="btn sm" onClick=${() => setText(SAMPLE_CSV)}>填入示例 CSV(含转义坑)</button>
</div>
<textarea class="csv" rows="5" placeholder="或直接把 CSV 文本粘贴到这里…" value=${text}
onChange=${e => setText(e.target.value)}></textarea>
${checked && html`
<div>
<p class="sub" style=${{ margin: "0 0 6px" }}>
解析出 ${checked.length} 行:合法 ${okRows.length} · 重复 id ${dupN} · 非法 ${badN}
</p>
<div class="preview-wrap">
<table class="list">
<thead>
<tr>
<th>#</th>
${HEADERS.map(h => html`<th key=${h}>${h}</th>`)}
<th>校验</th>
</tr>
</thead>
<tbody>
${checked.map((r, i) => html`
<tr key=${r.key} class=${"row-" + r.state}>
<td class="num">${i + 1}</td>
${HEADERS.map(h => html`<td key=${h}>${r.raw[h] ?? ""}</td>`)}
<td class="vmsg">
${r.state === "ok" && html`<span class="chip chip-on">合法</span>`}
${r.state === "dup" && html`<span class="chip chip-warn">${r.msg}</span>`}
${r.state === "bad" && html`<span class="err">${r.msg}</span>`}
</td>
</tr>`)}
${checked.length === 0 && html`
<tr><td class="empty" colSpan=${HEADERS.length + 2}>没有数据行</td></tr>`}
</tbody>
</table>
</div>
</div>`}
<div class="actions">
<button class="btn" onClick=${onClose}>取消</button>
<button class="btn primary" disabled=${okRows.length === 0}
onClick=${() => onImport(okRows.map(r => r.row), { dup: dupN, bad: badN })}>
导入 ${okRows.length} 行
</button>
</div>
</div>
</div>`;
}
function App() {
const [rows, setRows] = useState(SEED);
const [status, setStatus] = useState("全部");
const [importing, setImporting] = useState(false);
const [toast, setToast] = useState("");
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2600);
return () => clearTimeout(t);
}, [toast]);
const view = useMemo(
() => rows.filter(r => status === "全部" || r.status === status),
[rows, status],
);
const csv = useMemo(() => toCsv(view, EXPORT_COLUMNS), [view]);
const existingIds = useMemo(() => rows.map(r => r.id), [rows]);
const doExport = () => {
downloadCsv("members.csv", csv);
setToast(`已导出 ${view.length} 行到 members.csv`);
};
const doImport = (newRows, skipped) => {
setRows(rs => [...rs, ...newRows]);
setImporting(false);
const parts = [`已导入 ${newRows.length} 行`];
if (skipped.dup) parts.push(`跳过 ${skipped.dup} 行重复 id`);
if (skipped.bad) parts.push(`忽略 ${skipped.bad} 行非法`);
setToast(parts.join(","));
};
return html`
<div class="page">
<header class="head">
<div>
<h1>成员名单</h1>
<p class="sub">导出=当前筛选后的行 → RFC 4180 CSV;导入=先解析预览、逐行校验,确认后只入合法行</p>
</div>
</header>
<div class="toolbar">
<div class="seg" role="group" aria-label="状态筛选">
${["全部", ...STATUSES].map(s => html`
<button key=${s} class=${status === s ? "on" : ""} onClick=${() => setStatus(s)}>${s}</button>`)}
</div>
<span class="grow"></span>
<button class="btn" onClick=${doExport}>导出 CSV(${view.length} 行)</button>
<button class="btn primary" onClick=${() => setImporting(true)}>导入 CSV</button>
</div>
<details>
<summary>预览 CSV 文本(导出产物,不用下载也能看)</summary>
<pre class="csvbox">${csv}</pre>
</details>
<table class="list">
<thead>
<tr><th>ID</th><th>姓名</th><th>邮箱</th><th>角色</th><th>状态</th></tr>
</thead>
<tbody>
${view.map(r => html`
<tr key=${r.id}>
<td class="num">${r.id}</td>
<td style=${{ fontWeight: 550 }}>${r.name}</td>
<td>${r.email}</td>
<td>${r.role}</td>
<td><span class=${"chip " + (CHIP[r.status] || "chip-off")}>${r.status}</span></td>
</tr>`)}
${view.length === 0 && html`<tr><td class="empty" colSpan="5">该筛选下没有成员</td></tr>`}
</tbody>
</table>
<footer class="pager">
<span>共 ${rows.length} 名成员 · 当前筛选 ${view.length} 行(导出以此为准)</span>
</footer>
${importing && html`
<${ImportModal} existingIds=${existingIds} onImport=${doImport} onClose=${() => setImporting(false)} />`}
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>