diff --git a/.changeset/export-filename-and-system-field-i18n.md b/.changeset/export-filename-and-system-field-i18n.md new file mode 100644 index 0000000000..f46f425e34 --- /dev/null +++ b/.changeset/export-filename-and-system-field-i18n.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +'@objectstack/rest': patch +'@objectstack/plugin-reports': patch +--- + +导出文件名本地化 + 系统字段标签内置多语言回退。 + +**`@objectstack/rest` — 导出下载文件名**:`GET /data/:object/export` 的 `Content-Disposition` 不再是裸的 `<对象名>.<扩展名>`,改为「对象显示名-时间戳」:ASCII 兜底用 API 名(`filename="contracts-20260714-153045.xlsx"`),本地化标签(如中文)按 RFC 5987/6266 编码进 `filename*=UTF-8''…`(浏览器直接下载得到 `合同-20260714-153045.xlsx`)。新增导出 `exportContentDisposition(objectName, label, ext, now?)`。 + +**`@objectstack/spec` — 系统字段标签回退**:ObjectQL 注册表给每个对象注入的系统字段(`owner_id`/`created_at`/`created_by`/`updated_at`/`updated_by`)只带英文标签,自定义对象又没有对应的翻译条目,导致中文界面的列表表头、导出文件、导入模板里漏出 "Owner"/"Created At" 等英文。`translateObject` 现内置这五个字段的 en/zh-CN/ja-JP/es-ES 标签表(措辞与平台生成的翻译包一致),仅当字段仍是注入的英文默认值时套用——作者自定义的标签绝不覆盖;无翻译包时也生效(`translateObject` 不再因缺 bundle 而提前返回,REST 元数据翻译路径同步放宽,缓存 ETag 本就按 locale 分键,无缓存串味风险)。 + +**`@objectstack/plugin-reports` — 附件文件名**:定时报表附件的文件名清洗从「非 ASCII 全部替换成 `_`」改为按 Unicode 字母/数字保留(`\p{L}\p{N}`),中文计划名不再变成一串下划线。 + +**`@objectstack/rest` — 导入接受翻译后的选项标签(导出↔导入闭环)**:导出与导入模板写出的是*翻译后*的选项标签(如 `待规划`),但导入强制转换只认作者原始 schema 的标签/值,导致用户把自己刚导出的本地化文件原样导回时 select 字段全部报 `invalid_option`。`prepareImportRequest` 新增 `localizeSchema` 钩子(REST 导入路由传入 `translateMetaItem`),把当前 locale 的翻译标签合并进字段选项作为匹配同义词——作者标签与选项 code 照常匹配,非法值照常报错,翻译失败时降级为仅作者标签匹配。新增导出 `mergeLocalizedOptionSynonyms(metaMap, localizedMetaMap)`。 diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index d33d9d01f5..ff88e5e478 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -33,17 +33,30 @@ export const ShowcaseTranslationBundle = { title: { label: 'Title' }, project: { label: 'Project' }, assignee: { label: 'Assignee' }, - status: { label: 'Status' }, - priority: { label: 'Priority' }, + status: { + label: 'Status', + options: { backlog: 'Backlog', todo: 'To Do', in_progress: 'In Progress', in_review: 'In Review', done: 'Done' }, + }, + priority: { + label: 'Priority', + options: { low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent' }, + }, due_date: { label: 'Due Date' }, progress: { label: 'Progress' }, estimate_hours: { label: 'Estimate (h)' }, + done: { label: 'Done' }, start_date: { label: 'Start Date' }, end_date: { label: 'End Date' }, created_at: { label: 'Created' }, location: { label: 'Work Location' }, cover: { label: 'Cover' }, + labels: { label: 'Labels' }, notes: { label: 'Notes' }, + sync_status: { + label: 'Sync Status', + options: { synced: 'Synced', failed: 'Failed' }, + }, + sync_error: { label: 'Sync Error' }, }, }, showcase_account: { @@ -150,17 +163,30 @@ export const ShowcaseTranslationBundle = { title: { label: '标题' }, project: { label: '项目' }, assignee: { label: '负责人' }, - status: { label: '状态' }, - priority: { label: '优先级' }, + status: { + label: '状态', + options: { backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '评审中', done: '已完成' }, + }, + priority: { + label: '优先级', + options: { low: '低', medium: '中', high: '高', urgent: '紧急' }, + }, due_date: { label: '截止日期' }, progress: { label: '进度' }, estimate_hours: { label: '预计工时' }, + done: { label: '已完成' }, start_date: { label: '开始日期' }, end_date: { label: '结束日期' }, created_at: { label: '创建时间' }, location: { label: '工作地点' }, cover: { label: '封面' }, + labels: { label: '标签' }, notes: { label: '备注' }, + sync_status: { + label: '同步状态', + options: { synced: '已同步', failed: '同步失败' }, + }, + sync_error: { label: '同步错误' }, }, }, showcase_account: { diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 43b66171cc..f477616df2 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -66,6 +66,7 @@ export const TaskViews = defineView({ data, columns: [{ field: 'title' }, { field: 'project' }, { field: 'assignee' }, { field: 'status' }, { field: 'priority' }, { field: 'due_date' }], filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }], + exportOptions: ['csv', 'xlsx', 'json'], }, urgent: { label: 'Urgent', diff --git a/packages/plugins/plugin-reports/src/report-service.ts b/packages/plugins/plugin-reports/src/report-service.ts index de9c9cc037..fb7ec6718d 100644 --- a/packages/plugins/plugin-reports/src/report-service.ts +++ b/packages/plugins/plugin-reports/src/report-service.ts @@ -438,7 +438,9 @@ export class ReportService implements IReportService { subject, text: `Attached: ${result.rowCount} row(s).`, attachments: [{ - filename: `${(schedule.name ?? report.name).replace(/[^\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`, + // Keep unicode letters (CJK schedule names) — only strip + // filesystem-hostile characters, else 周报 becomes `__`. + filename: `${(schedule.name ?? report.name).replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^_+|_+$/g, '') || 'report'}-${ts.slice(0, 10)}.csv`, content: result.body, contentType: 'text/csv', }], diff --git a/packages/rest/src/export-format.test.ts b/packages/rest/src/export-format.test.ts index 0e3e7ad3d8..e454041602 100644 --- a/packages/rest/src/export-format.test.ts +++ b/packages/rest/src/export-format.test.ts @@ -8,7 +8,41 @@ */ import { describe, it, expect } from 'vitest'; -import { toArgb, cellFontColor, type ExportFieldMeta } from './export-format'; +import { toArgb, cellFontColor, exportContentDisposition, type ExportFieldMeta } from './export-format'; + +describe('exportContentDisposition', () => { + const NOW = new Date(2026, 6, 14, 15, 30, 45); // 2026-07-14 15:30:45 local + + it('uses the localized label in filename* and the API name as ASCII fallback', () => { + expect(exportContentDisposition('contracts', '合同', 'xlsx', NOW)).toBe( + `attachment; filename="contracts-20260714-153045.xlsx"; filename*=UTF-8''${encodeURIComponent('合同-20260714-153045.xlsx')}`, + ); + }); + + it('falls back to the API name when no label is available', () => { + expect(exportContentDisposition('contracts', undefined, 'csv', NOW)).toBe( + `attachment; filename="contracts-20260714-153045.csv"; filename*=UTF-8''contracts-20260714-153045.csv`, + ); + }); + + it('sanitizes hostile characters in both names', () => { + const header = exportContentDisposition('a/b', '合 同: v2?', 'csv', NOW); + expect(header).toContain('filename="a_b-20260714-153045.csv"'); + expect(header).toContain(`filename*=UTF-8''${encodeURIComponent('合 同_ v2-20260714-153045.csv')}`); + }); + + it('percent-encodes RFC 5987 non-attr-chars that encodeURIComponent leaves alone', () => { + const header = exportContentDisposition('obj', "a'b(c)", 'csv', NOW); + expect(header).toContain("filename*=UTF-8''a%27b%28c%29-20260714-153045.csv"); + }); + + it('zero-pads date and time parts', () => { + const early = new Date(2026, 0, 5, 9, 8, 7); + expect(exportContentDisposition('obj', undefined, 'json', early)).toContain( + 'filename="obj-20260105-090807.json"', + ); + }); +}); describe('toArgb', () => { it('expands 3-digit hex to opaque ARGB', () => { diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index dccdf4732e..e649bb16d2 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -37,6 +37,42 @@ export interface ExportFieldMeta { hasDefault?: boolean; } +/** + * Build the `Content-Disposition` header for an export download. + * + * The suggested filename is `