+
+ {hasFormulas &&
+
+ }
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts
index cdcde81ed3..42c2b1f066 100644
--- a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts
+++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts
@@ -4,11 +4,7 @@ export const zh = {
invalid: '无法打开此表格。请检查文件格式、内容或密码保护。',
tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。',
encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。',
- summary: '只读预览 · 显示已保存的公式结果',
- textSummary: '只读预览 · 按原始文本显示单元格',
- xlsLimitations: '旧版 XLS 显示数据、数字格式和基本布局;字体、边框、冻结窗格、图表和图片等暂不显示。',
- limitations: '图表、图片、数据透视表和条件格式暂不显示。',
- missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。',
+ formulaWarning: '此工作簿包含公式,显示结果可能缺失或不准确。',
retry: '重试',
} satisfies Record
@@ -21,11 +17,7 @@ export const en = {
invalid: 'This spreadsheet could not be opened. Check its format, contents, or password protection.',
tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.',
encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.',
- summary: 'Read-only preview · Saved formula results',
- textSummary: 'Read-only preview · Literal cell text',
- xlsLimitations: 'Legacy XLS shows data, number formats, and basic layout; fonts, borders, frozen panes, charts, and images are not displayed.',
- limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.',
- missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.',
+ formulaWarning: 'This workbook contains formulas. Displayed results may be missing or inaccurate.',
retry: 'Retry',
} satisfies Record
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx
index fa3bf9aaf2..ab9e905cf1 100644
--- a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx
@@ -16,15 +16,16 @@ import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx'
const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps
const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps
const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 }
+const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1 }
afterEach(() => { cleanup(); vi.resetAllMocks() })
it('shows loading then a workbook with editing and recalculation disabled', async () => {
- mocked.parse.mockResolvedValue({ ...value, missingResults: 1 })
+ mocked.parse.mockResolvedValue(formulaValue)
const view = render()
expect(screen.getByRole('status', { name: en.loading })).toBeDefined()
- await screen.findByText(en.summary)
- expect(screen.getByText(en.missingResults)).toBeDefined()
- expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] })
+ await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() })
+ expect(screen.getByRole('button', { name: en.formulaWarning })).toBeDefined()
+ expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: formulaValue.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] })
const signal = mocked.parse.mock.calls[0]![3] as AbortSignal
view.unmount()
expect(signal.aborted).toBe(true)
@@ -36,10 +37,10 @@ it('ignores a retired file result and cancels parsing when the bytes change', as
const view = render()
const signal = mocked.parse.mock.calls[0]![3] as AbortSignal
view.rerender()
- await screen.findByText(en.summary)
+ await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) })
expect(signal.aborted).toBe(true)
- finish({ ...value, missingResults: 1 })
- await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() })
+ finish(value)
+ await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) })
})
it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => {
@@ -48,7 +49,7 @@ it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows loc
const key = error === 'tooLarge' || error === 'timeout' || error === 'encoding' ? error : 'invalid'
await screen.findByText(en[key])
fireEvent.click(screen.getByRole('button', { name: en.retry }))
- await screen.findByText(en.summary)
+ await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() })
expect(mocked.parse).toHaveBeenCalledTimes(2)
})
@@ -61,7 +62,8 @@ it('rejects non-byte content without allocating a parser', () => {
it.each(['xlsx', 'xls', 'csv', 'tsv'])('loads the spreadsheet chunk for %s', async (format) => {
mocked.parse.mockResolvedValue(value)
render()
- await screen.findByText(format === 'csv' || format === 'tsv' ? en.textSummary : en.summary)
+ await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() })
+ expect(screen.queryByRole('button', { name: en.formulaWarning })).toBeNull()
expect(mocked.parse.mock.calls[0]![1]).toBe(format)
})
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts
index 31a1b35c58..edb37f7e9f 100644
--- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts
@@ -4,12 +4,12 @@ import { strToU8, unzipSync, zipSync } from 'fflate'
import { describe, expect, it } from 'vitest'
import { convertExcel } from '../src/client/excel/convert.ts'
import { Config } from '../src/config.ts'
-import { excelFixture } from './excel-fixture.ts'
+import { excelFixture, meetingMinutesFixture } from './excel-fixture.ts'
const limits = Config({}).excel
describe('Excel conversion', () => {
- it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => {
+ it('keeps values, dates, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => {
const input = await excelFixture()
const { sheets, missingResults } = await convertExcel(input, 'xlsx', limits)
expect(input.byteLength).toBeGreaterThan(0)
@@ -42,6 +42,17 @@ describe('Excel conversion', () => {
expect(missingResults).toBe(1)
})
+ it('opens workbooks with missing results and Chinese worksheet references', async () => {
+ const { sheets, missingResults } = await convertExcel(await meetingMinutesFixture(), 'xlsx', limits)
+ expect(sheets.map(sheet => sheet.name)).toEqual(['会议信息', '会议议程', '决议事项', '行动计划', '待确认问题', '风险与依赖', '统计看板', '填写说明'])
+ const dashboard = sheets[6]!
+ const cell = (r: number, c: number) => dashboard.celldata!.find(value => value.r === r && value.c === c)!.v
+ expect(cell(4, 1)).toMatchObject({ f: '=COUNTIF(行动计划!$G$4:$G$23,$A5)', m: '' })
+ expect(cell(4, 2)).toMatchObject({ f: '=IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))', m: '' })
+ expect(cell(4, 3)).toMatchObject({ f: '=IFERROR($B5/$B$10,0)', m: '' })
+ expect(missingResults).toBe(27)
+ })
+
it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => {
await expect(convertExcel(new Uint8Array([1, 2, 3]), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
const input = await excelFixture()
diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts
index ef9eefc42b..dcfc93abce 100644
--- a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts
+++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts
@@ -62,3 +62,31 @@ export async function excelHtmlFixture(): Promise> {
workbook.addWorksheet('Cached text').getCell('A1').value = { formula: '"cached"', result: excelHtmlText }
return new Uint8Array(await workbook.xlsx.writeBuffer())
}
+
+/** Build a meeting-minutes workbook whose formulas have no cached results. */
+export async function meetingMinutesFixture(): Promise> {
+ const workbook = new ExcelJS.Workbook()
+ const info = workbook.addWorksheet('会议信息')
+ info.getCell('A1').value = '会议纪要'
+ info.getCell('B11').value = { formula: 'IF(OR(B9="",B10=""),"",ROUND((B10-B9)*1440,0))' }
+ const agenda = workbook.addWorksheet('会议议程')
+ agenda.getCell('D16').value = { formula: 'SUM(D4:D15)' }
+ agenda.getCell('E16').value = { formula: 'SUM(E4:E15)' }
+ workbook.addWorksheet('决议事项')
+ const actions = workbook.addWorksheet('行动计划')
+ for (let row = 4; row <= 23; row += 1) {
+ actions.getCell(`I${row}`).value = { formula: `IF(AND($E${row}<>"",$E${row}"已完成",$G${row}<>"已取消"),TODAY()-$E${row},"")` }
+ }
+ workbook.addWorksheet('待确认问题')
+ workbook.addWorksheet('风险与依赖')
+ const dashboard = workbook.addWorksheet('统计看板')
+ dashboard.getCell('A1').value = '会议纪要 · 统计看板'
+ dashboard.getCell('A5').value = '未开始'
+ dashboard.getCell('B5').value = { formula: 'COUNTIF(行动计划!$G$4:$G$23,$A5)' }
+ dashboard.getCell('C5').value = { formula: 'IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))' }
+ dashboard.getCell('D5').value = { formula: 'IFERROR($B5/$B$10,0)' }
+ dashboard.getCell('D5').numFmt = '0%'
+ dashboard.getCell('B10').value = { formula: 'SUM(B5:B9)' }
+ workbook.addWorksheet('填写说明')
+ return new Uint8Array(await workbook.xlsx.writeBuffer())
+}
diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md
index 0ae42ec777..633a072fa7 100644
--- a/snapshots/web/document-preview/document.expected.md
+++ b/snapshots/web/document-preview/document.expected.md
@@ -76,6 +76,8 @@
- Opens without the Office conversion service
- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted
+- Formula workbooks use a compact warning beside fx; notice rows absent
+- Formatted percent copied: 80.0%; date copied: 2026-09-16
- Cached XLOOKUP result copied: 42; typing leaves it unchanged
- Formula bar is read-only; PDF body and editing toolbar absent
- HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML