nolo-cli 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-runtime/agentRecordConfig.ts +4 -0
- package/agent-runtime/hostAdapter.ts +2 -0
- package/agent-runtime/index.ts +7 -0
- package/agent-runtime/localLoop.ts +2 -0
- package/agent-runtime/platformChatProvider.ts +3 -0
- package/agent-runtime/runtimeToolPolicy.ts +92 -0
- package/agent-runtime/types.ts +42 -0
- package/agentRunCommand.ts +74 -1
- package/agentRuntimeCommands.ts +17 -89
- package/ai/agent/streamAgentChatTurn.ts +104 -20
- package/ai/chat/fetchUtils.native.ts +2 -0
- package/ai/chat/fetchUtils.ts +2 -0
- package/ai/chat/sendOpenAICompletionsRequest.ts +56 -0
- package/ai/chat/sendOpenAIResponseRequest.ts +64 -0
- package/ai/llm/kimi.ts +1 -1
- package/ai/llm/providers.ts +3 -0
- package/ai/llm/reasoningModels.ts +1 -0
- package/ai/skills/skillDocProtocol.ts +95 -3
- package/ai/taskRun/taskRunProtocol.ts +1 -0
- package/ai/tools/agent/agentTools.ts +17 -0
- package/ai/tools/agent/startAgentDialogTool.ts +53 -0
- package/ai/tools/modelUsageTools.ts +5 -0
- package/client/agentRun.test.ts +257 -7
- package/client/agentRun.ts +133 -34
- package/client/localRuntimeAdapter.test.ts +2 -0
- package/client/localRuntimeAdapter.ts +15 -2
- package/database/actions/common.ts +4 -3
- package/database/config.ts +19 -0
- package/machineCommands.ts +400 -45
- package/package.json +4 -2
- package/render/canvas/canvasEditContext.ts +127 -0
- package/render/canvas/canvasRuntime.ts +57 -0
- package/render/canvas/canvasSnapshotParser.ts +76 -0
- package/render/canvas/canvasTree.ts +308 -0
- package/render/canvas/types.ts +46 -0
- package/render/layout/deleteBehavior.ts +52 -0
- package/render/layout/mainLayoutSidebar.ts +17 -0
- package/render/layout/mainLayoutViewMode.ts +56 -0
- package/render/layout/topbarUtils.ts +87 -0
- package/render/layout/useDevReloadPending.ts +30 -0
- package/render/page/createPageAction.ts +183 -0
- package/render/page/docSlice.ts +468 -0
- package/render/page/server/createPage.ts +174 -0
- package/render/page/server/handleCreatePage.ts +91 -0
- package/render/page/server/index.ts +4 -0
- package/render/page/types.ts +17 -0
- package/render/page/useKeyboardSave.ts +48 -0
- package/render/styles/zIndex.ts +12 -0
- package/render/surf/WeatherIconStyles.ts +17 -0
- package/render/surf/color.ts +9 -0
- package/render/surf/config.ts +46 -0
- package/render/surf/screens/style.ts +1 -0
- package/render/surf/styles/ToggleButtonStyles.ts +8 -0
- package/render/surf/utils/groupedWeatherData.ts +32 -0
- package/render/surf/weatherUtils.ts +50 -0
- package/render/table/activityColumns.ts +6 -0
- package/render/table/createTableAction.ts +270 -0
- package/render/table/deleteTableAction.ts +129 -0
- package/render/table/fetchAndCacheTableRows.ts +174 -0
- package/render/table/tableSlice.ts +1106 -0
- package/render/table/tableView.ts +289 -0
- package/render/table/toolValueUtils.ts +363 -0
- package/render/table/types.ts +252 -0
- package/render/table/useCreateTable.ts +72 -0
- package/render/table/useTable.ts +61 -0
- package/render/table/utils/tableSerialization.ts +50 -0
- package/render/web/elements/artifactPreviewCode.ts +43 -0
- package/render/web/elements/artifactRuntimePreload.ts +52 -0
- package/render/web/elements/codeBlockAutoPreview.ts +10 -0
- package/render/web/elements/mermaidPreview.ts +21 -0
- package/render/web/ui/useInlineEdit.ts +135 -0
- package/tableCommands.ts +42 -5
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import type { TableColumn, TableMeta, TableView } from "./types";
|
|
2
|
+
|
|
3
|
+
export const NOLO_TASK_BOARD_TABLE_ID = "NOLOTASKBOARD";
|
|
4
|
+
|
|
5
|
+
export type TableDisplayMode =
|
|
6
|
+
| { type: "grid" }
|
|
7
|
+
| {
|
|
8
|
+
type: "kanban";
|
|
9
|
+
viewName: string;
|
|
10
|
+
groupColumnName: string;
|
|
11
|
+
visibleColumnNames: string[];
|
|
12
|
+
preferredGroupValues: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type TableActivityBadgeInfo = {
|
|
16
|
+
dialogId: string;
|
|
17
|
+
dialogKey?: string;
|
|
18
|
+
status?: string;
|
|
19
|
+
label: string;
|
|
20
|
+
title: string;
|
|
21
|
+
tone: "neutral" | "running" | "success" | "danger";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const TASK_BOARD_STATUS_ORDER = [
|
|
25
|
+
"待处理",
|
|
26
|
+
"进行中",
|
|
27
|
+
"阻塞",
|
|
28
|
+
"等待确认",
|
|
29
|
+
"已完成",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const ACTIVITY_STATUS_LABELS: Record<string, string> = {
|
|
33
|
+
pending: "等待中",
|
|
34
|
+
queued: "等待中",
|
|
35
|
+
accepted: "已接收",
|
|
36
|
+
running: "运行中",
|
|
37
|
+
completed: "已完成",
|
|
38
|
+
failed: "失败",
|
|
39
|
+
failed_to_start: "启动失败",
|
|
40
|
+
timed_out: "超时",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function findColumnById(columns: TableColumn[], columnId?: string) {
|
|
44
|
+
if (!columnId) return null;
|
|
45
|
+
return columns.find((column) => column.id === columnId) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findColumnByName(columns: TableColumn[], columnName: string) {
|
|
49
|
+
return columns.find((column) => column.name === columnName) ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function findColumnByNameOrLabel(
|
|
53
|
+
columns: TableColumn[],
|
|
54
|
+
candidates: string[]
|
|
55
|
+
) {
|
|
56
|
+
const normalizedCandidates = candidates.map((candidate) => candidate.trim());
|
|
57
|
+
return (
|
|
58
|
+
columns.find((column) => normalizedCandidates.includes(column.name)) ??
|
|
59
|
+
columns.find(
|
|
60
|
+
(column) =>
|
|
61
|
+
typeof column.label === "string" &&
|
|
62
|
+
normalizedCandidates.includes(column.label.trim())
|
|
63
|
+
) ??
|
|
64
|
+
null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function visibleNamesFromView(columns: TableColumn[], view: TableView) {
|
|
69
|
+
if (!Array.isArray(view.visibleColumnIds) || view.visibleColumnIds.length === 0) {
|
|
70
|
+
return columns.map((column) => column.name);
|
|
71
|
+
}
|
|
72
|
+
const names = view.visibleColumnIds
|
|
73
|
+
.map((columnId) => findColumnById(columns, columnId)?.name)
|
|
74
|
+
.filter((name): name is string => Boolean(name));
|
|
75
|
+
return names.length > 0 ? names : columns.map((column) => column.name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function valuesForGroupColumn(column: TableColumn) {
|
|
79
|
+
if (Array.isArray(column.options) && column.options.length > 0) {
|
|
80
|
+
return column.options;
|
|
81
|
+
}
|
|
82
|
+
if (column.name === "status") {
|
|
83
|
+
return TASK_BOARD_STATUS_ORDER;
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getColumnFilterOptions(
|
|
89
|
+
rows: Record<string, unknown>[],
|
|
90
|
+
column: Pick<TableColumn, "name" | "options"> | null
|
|
91
|
+
) {
|
|
92
|
+
if (!column) return [];
|
|
93
|
+
|
|
94
|
+
const values = new Set<string>();
|
|
95
|
+
const orderedValues: string[] = [];
|
|
96
|
+
const rowOnlyValues = new Set<string>();
|
|
97
|
+
if (Array.isArray(column.options)) {
|
|
98
|
+
column.options.forEach((option) => {
|
|
99
|
+
const value = String(option ?? "").trim();
|
|
100
|
+
if (value && !values.has(value)) {
|
|
101
|
+
values.add(value);
|
|
102
|
+
orderedValues.push(value);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
rows.forEach((row) => {
|
|
108
|
+
const value = String(row[column.name] ?? "").trim();
|
|
109
|
+
if (value && !values.has(value)) {
|
|
110
|
+
values.add(value);
|
|
111
|
+
rowOnlyValues.add(value);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return [
|
|
116
|
+
...orderedValues,
|
|
117
|
+
...Array.from(rowOnlyValues).sort((a, b) => a.localeCompare(b, "zh-Hans-CN")),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function filterRowsByTableFilters<T extends Record<string, unknown>>(
|
|
122
|
+
rows: T[],
|
|
123
|
+
filters: {
|
|
124
|
+
statusColumnName: string | null;
|
|
125
|
+
statusValue: string;
|
|
126
|
+
ownerColumnName: string | null;
|
|
127
|
+
ownerValue: string;
|
|
128
|
+
}
|
|
129
|
+
) {
|
|
130
|
+
const statusValue = filters.statusValue.trim();
|
|
131
|
+
const ownerValue = filters.ownerValue.trim();
|
|
132
|
+
|
|
133
|
+
return rows.filter((row) => {
|
|
134
|
+
if (
|
|
135
|
+
statusValue &&
|
|
136
|
+
filters.statusColumnName &&
|
|
137
|
+
String(row[filters.statusColumnName] ?? "").trim() !== statusValue
|
|
138
|
+
) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
ownerValue &&
|
|
144
|
+
filters.ownerColumnName &&
|
|
145
|
+
String(row[filters.ownerColumnName] ?? "").trim() !== ownerValue
|
|
146
|
+
) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeActivityRef(value: unknown) {
|
|
155
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
156
|
+
const ref = value as Record<string, unknown>;
|
|
157
|
+
if (ref.type !== "dialog") return null;
|
|
158
|
+
const dialogId = typeof ref.dialogId === "string" ? ref.dialogId.trim() : "";
|
|
159
|
+
if (!dialogId) return null;
|
|
160
|
+
const dialogKey = typeof ref.dialogKey === "string" && ref.dialogKey.trim()
|
|
161
|
+
? ref.dialogKey.trim()
|
|
162
|
+
: undefined;
|
|
163
|
+
const status = typeof ref.status === "string" && ref.status.trim()
|
|
164
|
+
? ref.status.trim()
|
|
165
|
+
: undefined;
|
|
166
|
+
return { dialogId, dialogKey, status };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function activityTone(status?: string): TableActivityBadgeInfo["tone"] {
|
|
170
|
+
if (status === "running") return "running";
|
|
171
|
+
if (status === "completed") return "success";
|
|
172
|
+
if (status === "failed" || status === "failed_to_start" || status === "timed_out") {
|
|
173
|
+
return "danger";
|
|
174
|
+
}
|
|
175
|
+
return "neutral";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function shortDialogId(dialogId: string) {
|
|
179
|
+
return dialogId.length > 10 ? `${dialogId.slice(0, 6)}…${dialogId.slice(-4)}` : dialogId;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function getLatestTableActivityBadge(row: Record<string, unknown>): TableActivityBadgeInfo | null {
|
|
183
|
+
const meta = row?.meta;
|
|
184
|
+
const metaRecord =
|
|
185
|
+
meta && typeof meta === "object" && !Array.isArray(meta) ? (meta as Record<string, unknown>) : null;
|
|
186
|
+
if (!metaRecord) return null;
|
|
187
|
+
|
|
188
|
+
const latest =
|
|
189
|
+
normalizeActivityRef(metaRecord.latestActivityRef) ??
|
|
190
|
+
(Array.isArray(metaRecord.activityRefs)
|
|
191
|
+
? normalizeActivityRef(metaRecord.activityRefs[metaRecord.activityRefs.length - 1])
|
|
192
|
+
: null);
|
|
193
|
+
if (!latest) return null;
|
|
194
|
+
|
|
195
|
+
const statusLabel = latest.status ? ACTIVITY_STATUS_LABELS[latest.status] ?? latest.status : "对话";
|
|
196
|
+
return {
|
|
197
|
+
...latest,
|
|
198
|
+
label: `${statusLabel} · ${shortDialogId(latest.dialogId)}`,
|
|
199
|
+
title: `Dialog ${latest.dialogId}${latest.status ? ` · ${statusLabel}` : ""}`,
|
|
200
|
+
tone: activityTone(latest.status),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const MARKDOWN_TABLE_SEPARATOR_CELL = /^:?-{3,}:?$/;
|
|
205
|
+
|
|
206
|
+
function markdownTableCellCount(line: string) {
|
|
207
|
+
const trimmed = line.trim();
|
|
208
|
+
if (!trimmed.includes("|")) return 0;
|
|
209
|
+
|
|
210
|
+
const content =
|
|
211
|
+
trimmed.startsWith("|") && trimmed.endsWith("|")
|
|
212
|
+
? trimmed.slice(1, -1)
|
|
213
|
+
: trimmed;
|
|
214
|
+
const cells = content.split("|").map((cell) => cell.trim());
|
|
215
|
+
return cells.length >= 2 && cells.every((cell) => cell.length > 0)
|
|
216
|
+
? cells.length
|
|
217
|
+
: 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isMarkdownTableSeparator(line: string) {
|
|
221
|
+
const trimmed = line.trim();
|
|
222
|
+
if (!trimmed.includes("|")) return false;
|
|
223
|
+
|
|
224
|
+
const content =
|
|
225
|
+
trimmed.startsWith("|") && trimmed.endsWith("|")
|
|
226
|
+
? trimmed.slice(1, -1)
|
|
227
|
+
: trimmed;
|
|
228
|
+
const cells = content.split("|").map((cell) => cell.trim());
|
|
229
|
+
return (
|
|
230
|
+
cells.length >= 2 &&
|
|
231
|
+
cells.every((cell) => MARKDOWN_TABLE_SEPARATOR_CELL.test(cell))
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function containsMarkdownTable(value: string) {
|
|
236
|
+
const lines = value.split(/\r?\n/);
|
|
237
|
+
|
|
238
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
239
|
+
if (!isMarkdownTableSeparator(lines[index])) continue;
|
|
240
|
+
|
|
241
|
+
const headerCellCount = markdownTableCellCount(lines[index - 1]);
|
|
242
|
+
if (headerCellCount < 2) continue;
|
|
243
|
+
|
|
244
|
+
const separatorCellCount = markdownTableCellCount(lines[index]);
|
|
245
|
+
if (separatorCellCount === headerCellCount) return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function shouldRenderKanbanMarkdownTable(tableId: string | undefined, value: string) {
|
|
252
|
+
return tableId === NOLO_TASK_BOARD_TABLE_ID && containsMarkdownTable(value);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function resolveTableDisplayMode(tableMeta: Pick<TableMeta, "tableId" | "columns" | "views">): TableDisplayMode {
|
|
256
|
+
const columns = Array.isArray(tableMeta.columns) ? tableMeta.columns : [];
|
|
257
|
+
const views = Array.isArray(tableMeta.views) ? tableMeta.views : [];
|
|
258
|
+
const defaultView = views.find((view) => view.isDefault) ?? views[0];
|
|
259
|
+
|
|
260
|
+
if (defaultView?.type === "kanban") {
|
|
261
|
+
const groupColumn = findColumnById(columns, defaultView.group?.columnId);
|
|
262
|
+
if (groupColumn) {
|
|
263
|
+
return {
|
|
264
|
+
type: "kanban",
|
|
265
|
+
viewName: defaultView.name || "看板",
|
|
266
|
+
groupColumnName: groupColumn.name,
|
|
267
|
+
visibleColumnNames: visibleNamesFromView(columns, defaultView),
|
|
268
|
+
preferredGroupValues: valuesForGroupColumn(groupColumn),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (tableMeta.tableId === NOLO_TASK_BOARD_TABLE_ID) {
|
|
274
|
+
const statusColumn = findColumnByName(columns, "status");
|
|
275
|
+
if (statusColumn) {
|
|
276
|
+
return {
|
|
277
|
+
type: "kanban",
|
|
278
|
+
viewName: "看板",
|
|
279
|
+
groupColumnName: statusColumn.name,
|
|
280
|
+
visibleColumnNames: ["title", "tags", "priority", "owner", "progress", "result"].filter((name) =>
|
|
281
|
+
Boolean(findColumnByName(columns, name))
|
|
282
|
+
),
|
|
283
|
+
preferredGroupValues: TASK_BOARD_STATUS_ORDER,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { type: "grid" };
|
|
289
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { TableColumn, TableMeta } from "./types";
|
|
2
|
+
|
|
3
|
+
const RESERVED_ROW_KEYS = new Set([
|
|
4
|
+
"dbKey",
|
|
5
|
+
"tenantId",
|
|
6
|
+
"tableId",
|
|
7
|
+
"rowId",
|
|
8
|
+
"createdAt",
|
|
9
|
+
"updatedAt",
|
|
10
|
+
"deletedAt",
|
|
11
|
+
"type",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export type RowFilterValue =
|
|
15
|
+
| string
|
|
16
|
+
| number
|
|
17
|
+
| boolean
|
|
18
|
+
| null
|
|
19
|
+
| string[]
|
|
20
|
+
| number[]
|
|
21
|
+
| boolean[];
|
|
22
|
+
|
|
23
|
+
export type RowFilters = Record<string, RowFilterValue>;
|
|
24
|
+
|
|
25
|
+
export type NormalizeRowOptions = {
|
|
26
|
+
mode?: "create" | "update";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type NormalizeRowResult = {
|
|
30
|
+
sanitizedValues: Record<string, any>;
|
|
31
|
+
ignoredColumns: string[];
|
|
32
|
+
errors: string[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const hasOwn = (value: unknown, key: string): boolean =>
|
|
36
|
+
!!value && typeof value === "object" && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, key);
|
|
37
|
+
|
|
38
|
+
export const getRowValueByPath = (row: any, key: string): { exists: boolean; value: any } => {
|
|
39
|
+
if (!key) return { exists: false, value: undefined };
|
|
40
|
+
if (hasOwn(row, key)) {
|
|
41
|
+
return { exists: true, value: row[key] };
|
|
42
|
+
}
|
|
43
|
+
if (!key.includes(".")) {
|
|
44
|
+
return { exists: false, value: undefined };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let current = row;
|
|
48
|
+
for (const part of key.split(".")) {
|
|
49
|
+
if (!part || !hasOwn(current, part)) {
|
|
50
|
+
return { exists: false, value: undefined };
|
|
51
|
+
}
|
|
52
|
+
current = current[part];
|
|
53
|
+
}
|
|
54
|
+
return { exists: true, value: current };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const setValueByPath = (target: Record<string, any>, key: string, value: any) => {
|
|
58
|
+
if (!key.includes(".")) {
|
|
59
|
+
target[key] = value;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const parts = key.split(".").filter(Boolean);
|
|
63
|
+
if (!parts.length) return;
|
|
64
|
+
let current = target;
|
|
65
|
+
for (const part of parts.slice(0, -1)) {
|
|
66
|
+
if (!current[part] || typeof current[part] !== "object" || Array.isArray(current[part])) {
|
|
67
|
+
current[part] = {};
|
|
68
|
+
}
|
|
69
|
+
current = current[part];
|
|
70
|
+
}
|
|
71
|
+
current[parts[parts.length - 1]] = value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isBlank = (value: unknown): boolean =>
|
|
75
|
+
value === undefined ||
|
|
76
|
+
value === null ||
|
|
77
|
+
(typeof value === "string" && value.trim() === "");
|
|
78
|
+
|
|
79
|
+
const normalizeBoolean = (value: unknown): boolean | undefined => {
|
|
80
|
+
if (typeof value === "boolean") return value;
|
|
81
|
+
if (typeof value === "number") {
|
|
82
|
+
if (value === 1) return true;
|
|
83
|
+
if (value === 0) return false;
|
|
84
|
+
}
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
const normalized = value.trim().toLowerCase();
|
|
87
|
+
if (["true", "1", "yes", "y"].includes(normalized)) return true;
|
|
88
|
+
if (["false", "0", "no", "n"].includes(normalized)) return false;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const normalizeDate = (value: unknown): string | undefined => {
|
|
94
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
95
|
+
return value.toISOString().slice(0, 10);
|
|
96
|
+
}
|
|
97
|
+
if (typeof value !== "string") return undefined;
|
|
98
|
+
const trimmed = value.trim();
|
|
99
|
+
if (!trimmed) return undefined;
|
|
100
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
|
|
101
|
+
const parsed = new Date(trimmed);
|
|
102
|
+
if (Number.isNaN(parsed.getTime())) return undefined;
|
|
103
|
+
return parsed.toISOString().slice(0, 10);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const normalizeDateTime = (value: unknown): string | undefined => {
|
|
107
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
108
|
+
return value.toISOString();
|
|
109
|
+
}
|
|
110
|
+
if (typeof value !== "string") return undefined;
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
if (!trimmed) return undefined;
|
|
113
|
+
const parsed = new Date(trimmed);
|
|
114
|
+
if (Number.isNaN(parsed.getTime())) return undefined;
|
|
115
|
+
return parsed.toISOString();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const normalizeMultiSelectInput = (value: unknown): string[] | undefined => {
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
const list = value
|
|
121
|
+
.map((item) =>
|
|
122
|
+
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
|
|
123
|
+
? String(item).trim()
|
|
124
|
+
: ""
|
|
125
|
+
)
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
return list;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof value === "string") {
|
|
131
|
+
const trimmed = value.trim();
|
|
132
|
+
if (!trimmed) return [];
|
|
133
|
+
return trimmed
|
|
134
|
+
.split(",")
|
|
135
|
+
.map((item) => item.trim())
|
|
136
|
+
.filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const normalizeSingleValue = (
|
|
143
|
+
column: TableColumn,
|
|
144
|
+
rawValue: unknown
|
|
145
|
+
): { value?: any; error?: string } => {
|
|
146
|
+
if (rawValue === undefined) {
|
|
147
|
+
return { value: undefined };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (rawValue === null) {
|
|
151
|
+
if (column.required) {
|
|
152
|
+
return { error: `字段 ${column.name} 是必填项,不能设为 null。` };
|
|
153
|
+
}
|
|
154
|
+
return { value: null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
switch (column.type ?? "text") {
|
|
158
|
+
case "number": {
|
|
159
|
+
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
|
160
|
+
return { value: rawValue };
|
|
161
|
+
}
|
|
162
|
+
if (typeof rawValue === "string" && rawValue.trim()) {
|
|
163
|
+
const parsed = Number(rawValue);
|
|
164
|
+
if (Number.isFinite(parsed)) {
|
|
165
|
+
return { value: parsed };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { error: `字段 ${column.name} 需要 number 值。` };
|
|
169
|
+
}
|
|
170
|
+
case "boolean": {
|
|
171
|
+
const normalized = normalizeBoolean(rawValue);
|
|
172
|
+
if (normalized === undefined) {
|
|
173
|
+
return { error: `字段 ${column.name} 需要 boolean 值。` };
|
|
174
|
+
}
|
|
175
|
+
return { value: normalized };
|
|
176
|
+
}
|
|
177
|
+
case "date": {
|
|
178
|
+
const normalized = normalizeDate(rawValue);
|
|
179
|
+
if (!normalized) {
|
|
180
|
+
return { error: `字段 ${column.name} 需要日期字符串(YYYY-MM-DD)。` };
|
|
181
|
+
}
|
|
182
|
+
return { value: normalized };
|
|
183
|
+
}
|
|
184
|
+
case "datetime": {
|
|
185
|
+
const normalized = normalizeDateTime(rawValue);
|
|
186
|
+
if (!normalized) {
|
|
187
|
+
return { error: `字段 ${column.name} 需要可解析的日期时间字符串。` };
|
|
188
|
+
}
|
|
189
|
+
return { value: normalized };
|
|
190
|
+
}
|
|
191
|
+
case "select": {
|
|
192
|
+
const normalized =
|
|
193
|
+
typeof rawValue === "string" || typeof rawValue === "number" || typeof rawValue === "boolean"
|
|
194
|
+
? String(rawValue).trim()
|
|
195
|
+
: "";
|
|
196
|
+
if (!normalized) {
|
|
197
|
+
return { error: `字段 ${column.name} 需要非空字符串。` };
|
|
198
|
+
}
|
|
199
|
+
if (Array.isArray(column.options) && column.options.length > 0 && !column.options.includes(normalized)) {
|
|
200
|
+
return {
|
|
201
|
+
error: `字段 ${column.name} 只能是以下值之一:${column.options.join(", ")}。`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return { value: normalized };
|
|
205
|
+
}
|
|
206
|
+
case "multi_select": {
|
|
207
|
+
const normalized = normalizeMultiSelectInput(rawValue);
|
|
208
|
+
if (!normalized) {
|
|
209
|
+
return { error: `字段 ${column.name} 需要字符串数组,或逗号分隔字符串。` };
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(column.options) && column.options.length > 0) {
|
|
212
|
+
const invalid = normalized.filter((item) => !column.options?.includes(item));
|
|
213
|
+
if (invalid.length > 0) {
|
|
214
|
+
return {
|
|
215
|
+
error: `字段 ${column.name} 包含非法选项:${invalid.join(", ")}。允许值:${column.options.join(", ")}。`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { value: normalized };
|
|
220
|
+
}
|
|
221
|
+
case "text":
|
|
222
|
+
default: {
|
|
223
|
+
if (typeof rawValue === "string") return { value: rawValue };
|
|
224
|
+
if (
|
|
225
|
+
typeof rawValue === "number" ||
|
|
226
|
+
typeof rawValue === "boolean"
|
|
227
|
+
) {
|
|
228
|
+
return { value: String(rawValue) };
|
|
229
|
+
}
|
|
230
|
+
return { value: rawValue };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const normalizeRowValues = (
|
|
236
|
+
columns: TableColumn[],
|
|
237
|
+
values: Record<string, any>,
|
|
238
|
+
options: NormalizeRowOptions = {}
|
|
239
|
+
): NormalizeRowResult => {
|
|
240
|
+
const mode = options.mode ?? "create";
|
|
241
|
+
const allowedColumns = new Map(columns.map((column) => [column.name, column]));
|
|
242
|
+
const sanitizedValues: Record<string, any> = {};
|
|
243
|
+
const ignoredColumns: string[] = [];
|
|
244
|
+
const errors: string[] = [];
|
|
245
|
+
|
|
246
|
+
for (const [key, value] of Object.entries(values || {})) {
|
|
247
|
+
if (RESERVED_ROW_KEYS.has(key)) {
|
|
248
|
+
ignoredColumns.push(key);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const column = allowedColumns.get(key);
|
|
253
|
+
if (!column) {
|
|
254
|
+
ignoredColumns.push(key);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const normalized = normalizeSingleValue(column, value);
|
|
259
|
+
if (normalized.error) {
|
|
260
|
+
errors.push(normalized.error);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (normalized.value !== undefined) {
|
|
265
|
+
sanitizedValues[key] = normalized.value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (mode === "create") {
|
|
270
|
+
const missingRequired = columns
|
|
271
|
+
.filter((column) => column.required)
|
|
272
|
+
.filter((column) => isBlank(sanitizedValues[column.name]))
|
|
273
|
+
.map((column) => column.name);
|
|
274
|
+
|
|
275
|
+
if (missingRequired.length > 0) {
|
|
276
|
+
errors.push(`缺少必填字段:${missingRequired.join(", ")}。`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
sanitizedValues,
|
|
282
|
+
ignoredColumns,
|
|
283
|
+
errors,
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export const applyRowFilters = (rows: any[], filters?: RowFilters): any[] => {
|
|
288
|
+
if (!filters || Object.keys(filters).length === 0) return rows;
|
|
289
|
+
|
|
290
|
+
return rows.filter((row) =>
|
|
291
|
+
Object.entries(filters).every(([key, expected]) => {
|
|
292
|
+
const { exists, value: actual } = getRowValueByPath(row, key);
|
|
293
|
+
if (!exists) return false;
|
|
294
|
+
if (Array.isArray(expected)) {
|
|
295
|
+
if (Array.isArray(actual)) {
|
|
296
|
+
return expected.every((item) => actual.includes(item));
|
|
297
|
+
}
|
|
298
|
+
return expected.includes(actual);
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(actual)) {
|
|
301
|
+
return actual.includes(expected);
|
|
302
|
+
}
|
|
303
|
+
return actual === expected;
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const sortRows = (
|
|
309
|
+
rows: any[],
|
|
310
|
+
sortBy = "updatedAt",
|
|
311
|
+
sortOrder: "asc" | "desc" = "desc"
|
|
312
|
+
): any[] => {
|
|
313
|
+
const factor = sortOrder === "asc" ? 1 : -1;
|
|
314
|
+
return [...rows].sort((a, b) => {
|
|
315
|
+
const left = getRowValueByPath(a, sortBy).value;
|
|
316
|
+
const right = getRowValueByPath(b, sortBy).value;
|
|
317
|
+
|
|
318
|
+
if (left == null && right == null) return 0;
|
|
319
|
+
if (left == null) return 1;
|
|
320
|
+
if (right == null) return -1;
|
|
321
|
+
|
|
322
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
323
|
+
return (left - right) * factor;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const leftTs = Date.parse(String(left));
|
|
327
|
+
const rightTs = Date.parse(String(right));
|
|
328
|
+
if (Number.isFinite(leftTs) && Number.isFinite(rightTs)) {
|
|
329
|
+
return (leftTs - rightTs) * factor;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return String(left).localeCompare(String(right)) * factor;
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export const pickRowColumns = (
|
|
337
|
+
row: any,
|
|
338
|
+
columns?: string[],
|
|
339
|
+
options: { includeBaseFields?: boolean } = {}
|
|
340
|
+
): any => {
|
|
341
|
+
if (!Array.isArray(columns) || columns.length === 0) return row;
|
|
342
|
+
|
|
343
|
+
const picked: Record<string, any> = {};
|
|
344
|
+
for (const key of columns) {
|
|
345
|
+
const { exists, value } = getRowValueByPath(row, key);
|
|
346
|
+
if (exists) {
|
|
347
|
+
setValueByPath(picked, key, value);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (options.includeBaseFields !== false) {
|
|
352
|
+
for (const baseKey of ["dbKey", "rowId", "tenantId", "tableId", "createdAt", "updatedAt"]) {
|
|
353
|
+
if (baseKey in row) {
|
|
354
|
+
picked[baseKey] = row[baseKey];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return picked;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export const formatKnownColumns = (tableMeta: TableMeta): string =>
|
|
363
|
+
tableMeta.columns.map((column) => column.name).join(", ") || "(无列定义)";
|