Skip to content

Commit e38568b

Browse files
authored
feat(rest): 列表导出路由 (csv/xlsx/json) + 类型感知格式化 + 表头本地化 (#2487)
1 parent b0510ce commit e38568b

8 files changed

Lines changed: 1262 additions & 36 deletions

File tree

examples/app-crm/src/views/opportunity.view.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const OpportunityViews = defineView({
1616
{ field: 'expected_revenue' },
1717
{ field: 'close_date' },
1818
],
19+
exportOptions: ['csv', 'xlsx', 'json'],
1920
},
2021
listViews: {
2122
all: {
@@ -31,6 +32,7 @@ export const OpportunityViews = defineView({
3132
{ field: 'expected_revenue' },
3233
{ field: 'close_date' },
3334
],
35+
exportOptions: ['csv', 'xlsx', 'json'],
3436
},
3537
pipeline: {
3638
label: 'Pipeline (Kanban)',

packages/rest/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@
2222
"@objectstack/core": "workspace:*",
2323
"@objectstack/service-package": "workspace:*",
2424
"@objectstack/spec": "workspace:*",
25+
"exceljs": "^4.4.0",
2526
"zod": "^4.4.3"
2627
},
2728
"devDependencies": {
29+
"@objectstack/metadata-protocol": "workspace:*",
30+
"@objectstack/objectql": "workspace:*",
31+
"@types/node": "^26.0.1",
2832
"typescript": "^6.0.3",
2933
"vitest": "^4.1.9"
3034
},

packages/rest/src/export-format.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Type-aware value formatting for the streaming data export route
3+
* (`GET /data/:object/export`).
4+
*
5+
* The raw rows returned by `findData` carry *storage* values: lookup / user
6+
* fields hold ids (or, when `$expand`-ed, nested records), select fields hold
7+
* option codes, booleans hold true/false, dates hold ISO strings. None of those
8+
* read well in a spreadsheet. These helpers turn each value into a human
9+
* readable cell using the object's field metadata.
10+
*
11+
* Contract: when no field metadata is available (schema lookup failed or carried
12+
* no fields) every helper is a pass-through, so the export stays byte-for-byte
13+
* identical to the un-formatted behaviour.
14+
*/
15+
16+
export interface ExportFieldMeta {
17+
name: string;
18+
type?: string;
19+
label?: string;
20+
options?: Array<{ label?: string; value?: unknown }>;
21+
/** Target object for lookup / master_detail / user fields. */
22+
reference?: string;
23+
/** Field on the referenced record to show as its label. */
24+
displayField?: string;
25+
}
26+
27+
/** Field types whose stored value points at another record. */
28+
const REFERENCE_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);
29+
30+
/** Field types whose stored value maps to a static option label. */
31+
const OPTION_TYPES = new Set(['select', 'radio']);
32+
const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']);
33+
34+
/**
35+
* Keys tried, in order, to derive a referenced record's display value when the
36+
* field carries no explicit `displayField`.
37+
*/
38+
const NAME_KEY_FALLBACKS = [
39+
'name', 'title', 'label', 'full_name', 'fullName', 'display_name', 'username', 'email',
40+
];
41+
42+
/**
43+
* Build a field-name → metadata map from an object schema (best-effort).
44+
*
45+
* Accepts both shapes `fields` appears in across the stack: the runtime
46+
* `ObjectSchema.fields` is a `Record<fieldName, FieldDefinition>` object map
47+
* (the form served by the engine registry / `getMetaItem`), while some callers
48+
* and fixtures hand back a plain `FieldDefinition[]` array. A field's name is
49+
* taken from its own `name`, falling back to the map key.
50+
*/
51+
export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta> {
52+
const map = new Map<string, ExportFieldMeta>();
53+
const fields = (schema as { fields?: unknown })?.fields;
54+
55+
// Normalize either shape to a list of [name, definition] entries.
56+
let entries: Array<[string, any]>;
57+
if (Array.isArray(fields)) {
58+
entries = fields
59+
.filter((f) => f && typeof f === 'object')
60+
.map((f) => [typeof f.name === 'string' ? f.name : '', f] as [string, any]);
61+
} else if (fields && typeof fields === 'object') {
62+
entries = Object.entries(fields as Record<string, any>).map(
63+
([key, def]) => [
64+
def && typeof def === 'object' && typeof def.name === 'string' ? def.name : key,
65+
def,
66+
] as [string, any],
67+
);
68+
} else {
69+
return map;
70+
}
71+
72+
for (const [name, f] of entries) {
73+
if (!name || !f || typeof f !== 'object') continue;
74+
map.set(name, {
75+
name,
76+
type: typeof f.type === 'string' ? f.type : undefined,
77+
label: typeof f.label === 'string' ? f.label : undefined,
78+
options: Array.isArray(f.options) ? f.options : undefined,
79+
reference: typeof f.reference === 'string' ? f.reference : undefined,
80+
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
81+
});
82+
}
83+
return map;
84+
}
85+
86+
/**
87+
* Reference-typed field names that should be `$expand`-ed so their stored ids
88+
* resolve to the referenced record (and thus to a readable name).
89+
*/
90+
export function referenceFieldNames(metaMap: Map<string, ExportFieldMeta>): string[] {
91+
const out: string[] = [];
92+
for (const meta of metaMap.values()) {
93+
if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) out.push(meta.name);
94+
}
95+
return out;
96+
}
97+
98+
/** Header label for a column: schema label when present, else the field name. */
99+
export function headerLabel(field: string, metaMap: Map<string, ExportFieldMeta>): string {
100+
return metaMap.get(field)?.label || field;
101+
}
102+
103+
function pad2(n: number): string {
104+
return n < 10 ? `0${n}` : String(n);
105+
}
106+
107+
function toDate(value: unknown): Date | null {
108+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
109+
if (typeof value === 'number' || typeof value === 'string') {
110+
const d = new Date(value);
111+
return Number.isNaN(d.getTime()) ? null : d;
112+
}
113+
return null;
114+
}
115+
116+
/** `YYYY-MM-DD` (date) or `YYYY-MM-DD HH:mm:ss` (datetime), in UTC. */
117+
function formatDate(value: unknown, withTime: boolean): unknown {
118+
const d = toDate(value);
119+
if (!d) return value;
120+
const ymd = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
121+
if (!withTime) return ymd;
122+
return `${ymd} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
123+
}
124+
125+
function optionLabel(value: unknown, options?: Array<{ label?: string; value?: unknown }>): unknown {
126+
if (!options) return value;
127+
const hit = options.find((o) => o && o.value === value);
128+
return hit?.label ?? value;
129+
}
130+
131+
function displayFromRecord(rec: Record<string, unknown>, displayField?: string): string {
132+
if (displayField && rec[displayField] != null) return String(rec[displayField]);
133+
for (const k of NAME_KEY_FALLBACKS) {
134+
const v = rec[k];
135+
if (v != null && typeof v !== 'object') return String(v);
136+
}
137+
if (rec.id != null) return String(rec.id);
138+
try { return JSON.stringify(rec); } catch { return String(rec); }
139+
}
140+
141+
function formatReference(value: unknown, displayField?: string): unknown {
142+
const one = (v: unknown): unknown =>
143+
v && typeof v === 'object' ? displayFromRecord(v as Record<string, unknown>, displayField) : v;
144+
if (Array.isArray(value)) return value.map(one).join(', ');
145+
return one(value);
146+
}
147+
148+
/** Format one storage value into a display value using its field metadata. */
149+
export function formatCellValue(value: unknown, meta?: ExportFieldMeta): unknown {
150+
if (value === null || value === undefined) return value;
151+
if (!meta || !meta.type) return value;
152+
const t = meta.type;
153+
if (t === 'boolean' || t === 'toggle') {
154+
if (value === true || value === 'true' || value === 1) return '是';
155+
if (value === false || value === 'false' || value === 0) return '否';
156+
return value;
157+
}
158+
if (OPTION_TYPES.has(t)) return optionLabel(value, meta.options);
159+
if (MULTI_OPTION_TYPES.has(t)) {
160+
const arr = Array.isArray(value) ? value : [value];
161+
return arr.map((v) => optionLabel(v, meta.options)).join(', ');
162+
}
163+
if (t === 'date') return formatDate(value, false);
164+
if (t === 'datetime') return formatDate(value, true);
165+
if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField);
166+
return value;
167+
}
168+
169+
/** Ordered display cells for one row — the CSV / XLSX column path. */
170+
export function formatRowCells(
171+
row: Record<string, unknown>,
172+
fields: string[],
173+
metaMap: Map<string, ExportFieldMeta>,
174+
): unknown[] {
175+
return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f)));
176+
}
177+
178+
/**
179+
* Format a row for JSON output: readable values for known fields, every other
180+
* key left untouched. Returns the original object reference when nothing needs
181+
* formatting so the stream stays byte-identical to the un-formatted path.
182+
*/
183+
export function formatRowForJson(
184+
row: Record<string, unknown>,
185+
metaMap: Map<string, ExportFieldMeta>,
186+
): Record<string, unknown> {
187+
if (metaMap.size === 0 || !row || typeof row !== 'object') return row;
188+
let copy: Record<string, unknown> | null = null;
189+
for (const key of Object.keys(row)) {
190+
const meta = metaMap.get(key);
191+
if (!meta) continue;
192+
const formatted = formatCellValue(row[key], meta);
193+
if (formatted !== row[key]) {
194+
if (!copy) copy = { ...row };
195+
copy[key] = formatted;
196+
}
197+
}
198+
return copy ?? row;
199+
}

0 commit comments

Comments
 (0)