mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
资料上传支持 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:
+1
-1
@@ -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 也能读。数字咨询师会检索资料并注明出处。
|
||||
- **企业画像**:按“事实 / 假设 / 未知 / 待补”分类记录企业情况,每条注明来源。可以让数字咨询师根据资料起草画像条目(需确认写入,起草的条目一律为草稿)。
|
||||
- 画像条目由咨询师**复核**、企业项目负责人**确认**;他人修改已复核或已确认的条目,会回到草稿。两人同时编辑时,后保存的一方会被提示刷新。
|
||||
- 画像摘要会随页面上下文提供给各阶段的数字咨询师。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+11
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user