资料上传支持 Excel 全格式:xlsx、xls、xlsm、xlsb、ods

- 改用 SheetJS(官方 CDN 0.20.3,devDependency)读取表格:按工作表逐行转文字,
  公式取计算结果,日期、百分比按单元格格式显示,去掉空行和行尾空格
- 兼容业务系统导出的网页格式 .xls;损坏或加密的文件给出明确提示,不存乱码
- 资料页与使用说明列出支持的格式

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MMDFmVTvRksi1ZT8aPGXoL
This commit is contained in:
Claude
2026-10-09 21:29:30 +00:00
parent 3d44626d96
commit 66847150be
6 changed files with 107 additions and 19 deletions
+1 -1
View File
@@ -64,7 +64,7 @@
### 7. 资料与企业画像
- **资料库**:上传经营总结、行业报告等(Word、PDF、Excel、txt / md / csv,单个不超过 20MB),平台在浏览器里提取文字后保存;也可以直接粘贴文字。数字咨询师会检索资料并注明出处。
- **资料库**:上传经营总结、行业报告、经营数据表等(Word docx、PDF、Excel xlsx / xls / xlsm / xlsb / ods、txt / md / csv,单个不超过 20MB),平台在浏览器里提取文字后保存;也可以直接粘贴文字。Excel 按工作表逐行读取,公式取计算结果、日期和百分比按表格里的显示;业务系统导出的网页格式 .xls 也能读。数字咨询师会检索资料并注明出处。
- **企业画像**:按“事实 / 假设 / 未知 / 待补”分类记录企业情况,每条注明来源。可以让数字咨询师根据资料起草画像条目(需确认写入,起草的条目一律为草稿)。
- 画像条目由咨询师**复核**、企业项目负责人**确认**;他人修改已复核或已确认的条目,会回到草稿。两人同时编辑时,后保存的一方会被提示刷新。
- 画像摘要会随页面上下文提供给各阶段的数字咨询师。
+1
View File
@@ -90,6 +90,7 @@
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vitest": "^4.1.10",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76"
},
"publishConfig": {
+11
View File
@@ -257,6 +257,9 @@ importers:
vitest:
specifier: ^4.1.10
version: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
xlsx:
specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -5887,6 +5890,12 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz:
resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz}
version: 0.20.3
engines: {node: '>=0.8'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true
@@ -12127,6 +12136,8 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {}
[email protected]:
dependencies:
sax: 1.6.1
+32 -15
View File
@@ -1,7 +1,9 @@
// Turn an uploaded file into plain text in the browser; only the text is stored. The parsers
// load on demand so they stay out of the main bundle.
export const MATERIAL_ACCEPT = ".txt,.md,.csv,.docx,.pdf,.xlsx";
/** Spreadsheet formats read with SheetJS: Excel 2007+ and 97–2003, macro / binary workbooks, OpenDocument. */
export const SPREADSHEET_EXTENSIONS = ["xlsx", "xls", "xlsm", "xlsb", "ods"];
export const MATERIAL_ACCEPT = [".txt", ".md", ".csv", ".docx", ".pdf", ...SPREADSHEET_EXTENSIONS.map((e) => `.${e}`)].join(",");
export const MATERIAL_MAX_BYTES = 20 * 1024 * 1024;
export class UnsupportedFileError extends Error {}
@@ -29,19 +31,34 @@ export async function extractText(file: File): Promise<string> {
}
return pages.join("\n\n");
}
if (ext === "xlsx") {
const { Workbook } = await import("exceljs");
const book = new Workbook();
await book.xlsx.load(await file.arrayBuffer());
const lines: string[] = [];
book.eachSheet((sheet) => {
lines.push(`【${sheet.name}】`);
sheet.eachRow((row) => {
const values = (row.values as unknown[]).slice(1).map((v) => (v && typeof v === "object" && "text" in (v as object) ? String((v as { text: unknown }).text) : v ?? ""));
lines.push(values.join("\t"));
});
});
return lines.join("\n");
if (SPREADSHEET_EXTENSIONS.includes(ext)) return spreadsheetText(await file.arrayBuffer());
throw new UnsupportedFileError("暂不支持这种格式:请上传 txt、md、csv、docx、pdf 或 Excel(xlsx、xls 等),或把文字粘贴进来");
}
/**
* Every sheet as a 【name】 heading followed by its rows, cells joined by tabs. Cells show
* their formatted text as Excel would (formula results, dates, percentages); empty rows and
* trailing empty cells are dropped.
*/
export async function spreadsheetText(data: ArrayBuffer | Uint8Array): Promise<string> {
const XLSX = await import("xlsx");
let book: import("xlsx").WorkBook;
try {
book = XLSX.read(data, { type: "array", cellDates: true, dense: true });
} catch {
throw new UnsupportedFileError("读不了这个表格文件,可能已损坏或设了打开密码;请另存为 xlsx 后再上传");
}
throw new UnsupportedFileError("暂不支持这种格式:请上传 txt、md、csv、docx、pdf 或 xlsx,或把文字粘贴进来");
const parts: string[] = [];
for (const name of book.SheetNames) {
const rows = XLSX.utils.sheet_to_json<unknown[]>(book.Sheets[name], { header: 1, raw: false, defval: "", blankrows: false });
const lines = rows
.map((row) => {
const cells = row.map((v) => String(v ?? "").replace(/\s*\n\s*/g, " ").trim());
while (cells.length && !cells[cells.length - 1]) cells.pop();
return cells.join("\t");
})
.filter((line) => line.trim());
if (lines.length) parts.push(`【${name}】`, ...lines);
}
return parts.join("\n");
}
+7 -3
View File
@@ -26,7 +26,7 @@ import { Textarea } from "@/components/ui/textarea";
import { type ProposedChange, applyChanges } from "@/lib/dingze/changes";
import { PROPOSE_TOOL } from "@/lib/dingze/coach";
import { errorMessage } from "@/lib/dingze/errors";
import { MATERIAL_ACCEPT, extractText } from "@/lib/dingze/extract-text";
import { MATERIAL_ACCEPT, SPREADSHEET_EXTENSIONS, extractText } from "@/lib/dingze/extract-text";
import { SEARCH_MATERIALS_TOOL } from "@/lib/dingze/material-tools";
import { type MaterialSummary, useAddMaterial, useDeleteMaterial, useMaterial, useMaterials, useProfile, useSaveProfile } from "@/lib/dingze/materials-api";
import { formatTime } from "@/lib/dingze/records";
@@ -108,7 +108,11 @@ function MaterialLibrary({ materials, canEdit, canDeleteAny, currentUserId }: {
try {
const text = await extractText(file);
if (!text.trim()) {
toast.error(`《${file.name}》没有读到文字,可能是扫描件;请粘贴文字或上传可复制文字的版本`);
toast.error(
SPREADSHEET_EXTENSIONS.some((e) => file.name.toLowerCase().endsWith(`.${e}`))
? `《${file.name}》的表格里没有内容`
: `《${file.name}》没有读到文字,可能是扫描件;请粘贴文字或上传可复制文字的版本`
);
continue;
}
const result = await add.mutateAsync({ title: file.name.replace(/\.[^.]+$/, ""), kind: "file", fileName: file.name, size: file.size, text });
@@ -142,7 +146,7 @@ function MaterialLibrary({ materials, canEdit, canDeleteAny, currentUserId }: {
</div>
{materials.length === 0 ? (
<p className="rounded-xl border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
还没有资料。可上传 txt、md、csv、docx、pdf、xlsx(不超过 20 MB),或直接粘贴文字。
还没有资料。可上传 Word(docx)、PDF、Excel(xlsx、xls、xlsm、xlsb、ods)、txt、md、csv(不超过 20 MB),或直接粘贴文字。
</p>
) : (
<ul className="overflow-hidden rounded-xl border bg-card">
+55
View File
@@ -0,0 +1,55 @@
import * as XLSX from "xlsx";
import { describe, expect, it } from "vitest";
import { MATERIAL_ACCEPT, UnsupportedFileError, spreadsheetText } from "@/lib/dingze/extract-text";
function workbook(bookType: XLSX.BookType): Uint8Array {
const book = XLSX.utils.book_new();
const sales = XLSX.utils.aoa_to_sheet([
["业务", "2025 销量(万方)", "占比"],
["车用气", 360, 0.4],
[],
["工商业", 540, 0.6],
]);
sales["C2"].z = "0%";
sales["C4"].z = "0%";
// A formula with its cached result, as Excel saves it.
sales["B5"] = { t: "n", f: "SUM(B2:B4)", v: 900 };
sales["A5"] = { t: "s", v: "合计" };
sales["!ref"] = "A1:C5";
XLSX.utils.book_append_sheet(book, sales, "销量");
XLSX.utils.book_append_sheet(book, XLSX.utils.aoa_to_sheet([["事项", "日期"], ["站点开工", new Date(Date.UTC(2026, 2, 15))]], { cellDates: true, dateNF: "yyyy-mm-dd" }), "节点");
XLSX.utils.book_append_sheet(book, XLSX.utils.aoa_to_sheet([]), "空表");
return new Uint8Array(XLSX.write(book, { type: "array", bookType }));
}
describe("spreadsheet materials", () => {
for (const bookType of ["xlsx", "xls", "ods"] as const) {
it(`reads every sheet of a .${bookType} as text`, async () => {
const text = await spreadsheetText(workbook(bookType));
const lines = text.split("\n");
expect(lines[0]).toBe("【销量】");
// ODS as written here keeps the value but not the percent format.
expect(lines).toContain(bookType === "ods" ? "车用气\t360\t0.4" : "车用气\t360\t40%");
expect(lines).toContain("合计\t900");
expect(lines).not.toContain("");
expect(text).toContain("【节点】");
expect(text).toMatch(/站点开工\t2026-03-15/);
expect(text).not.toContain("【空表】");
});
}
it("explains a damaged file instead of storing garbage", async () => {
await expect(spreadsheetText(workbook("xlsx").slice(0, 200))).rejects.toBeInstanceOf(UnsupportedFileError);
await expect(spreadsheetText(workbook("xls").slice(0, 600))).rejects.toBeInstanceOf(UnsupportedFileError);
});
it("reads the HTML tables that business systems export with an .xls name", async () => {
const html = "<html><body><table><tr><td>项目</td><td>金额</td></tr><tr><td>光伏</td><td>120</td></tr></table></body></html>";
expect(await spreadsheetText(new TextEncoder().encode(html))).toBe("【Sheet1】\n项目\t金额\n光伏\t120");
});
it("offers the Excel formats in the file picker", () => {
for (const ext of [".xlsx", ".xls", ".xlsm", ".xlsb", ".ods", ".docx", ".pdf"]) expect(MATERIAL_ACCEPT.split(",")).toContain(ext);
});
});