@arronqzy/react-view 1.0.3 → 1.0.7

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.
Files changed (43) hide show
  1. package/dist/styles.css +555 -15
  2. package/package.json +14 -7
  3. package/readme.md +74 -5
  4. package/src/core/template.tsx +12 -2
  5. package/src/panel/ReactViewOnlinePreview.tsx +18 -8
  6. package/src/panel/ReactViewPanel.tsx +403 -177
  7. package/src/panel/ai/AssistantChatPanel.tsx +895 -0
  8. package/src/panel/ai/AssistantMarkdown.tsx +256 -0
  9. package/src/panel/ai/applyAssistantAction.ts +652 -0
  10. package/src/panel/ai/revealConfigField.ts +137 -0
  11. package/src/panel/components/ConfigHintIcon.tsx +50 -0
  12. package/src/panel/components/ElementsLayer.tsx +39 -22
  13. package/src/panel/components/MaterialSidebar.tsx +157 -109
  14. package/src/panel/components/MoveableLayer.tsx +75 -61
  15. package/src/panel/components/PanelCanvas.tsx +81 -64
  16. package/src/panel/components/PanelConfigSidebar.tsx +471 -448
  17. package/src/panel/components/SelectLayer.tsx +1 -0
  18. package/src/panel/components/ViewElementScopePanel.tsx +13 -12
  19. package/src/panel/components/WorkspaceConfigSidebar.tsx +5 -2
  20. package/src/panel/components/WorkspaceProjectNav.tsx +25 -14
  21. package/src/panel/components/WorkspaceStageSplit.tsx +3 -1
  22. package/src/panel/components/scope-config/ScopeConfigContext.tsx +9 -0
  23. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.tsx +242 -0
  24. package/src/panel/components/scope-config/ScopeTemplateUsageHint.tsx +12 -0
  25. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +6 -3
  26. package/src/panel/components/table/ConditionEditor.tsx +205 -0
  27. package/src/panel/components/table/JsonCodeEditor.tsx +115 -0
  28. package/src/panel/components/table/PanelConfigTableSection.tsx +2192 -0
  29. package/src/panel/components/table/TableNodeContent.tsx +544 -0
  30. package/src/panel/constants/messages.ts +76 -34
  31. package/src/panel/hooks/usePanelElements.ts +64 -52
  32. package/src/panel/hooks/useRafThrottledScroll.ts +30 -0
  33. package/src/panel/hooks/useWorkspaceProjects.ts +11 -8
  34. package/src/panel/types.ts +6 -0
  35. package/src/panel/utils/chartOptionBuilder.ts +12 -1
  36. package/src/panel/utils/panelElementDefaults.ts +69 -39
  37. package/src/panel/utils/scope-field-labels.ts +51 -37
  38. package/src/panel/utils/scope-template-chart.ts +92 -0
  39. package/src/panel/utils/scope-template-preview.ts +128 -0
  40. package/src/panel/utils/scope-template-spread.ts +252 -0
  41. package/src/panel/utils/scope-template-warnings.ts +131 -11
  42. package/src/panel/utils/scope-template.ts +105 -3
  43. package/src/tailwind.css +36 -15
@@ -0,0 +1,2192 @@
1
+ import type {
2
+ PanelTableConfig,
3
+ TableCellWidget,
4
+ TableColumnConfig,
5
+ TableStyleRule,
6
+ TableValueMapRule,
7
+ TableWidgetProps,
8
+ TableColorMapEntry,
9
+ TableHrefRule,
10
+ TableProgressRule,
11
+ TableTooltipPlacement,
12
+ } from "@arronqzy/view-table";
13
+ import {
14
+ coerceBooleanMapTarget,
15
+ createDefaultCondition,
16
+ findBooleanValueMapConflicts,
17
+ generateMockTableRows,
18
+ stringifyMockTableRows,
19
+ type TableMockLocale,
20
+ } from "@arronqzy/view-table";
21
+ import type { PanelElement } from "../../types";
22
+ import { ConfigHintIcon } from "../ConfigHintIcon";
23
+ import { ConditionEditor } from "./ConditionEditor";
24
+ import { JsonCodeEditor } from "./JsonCodeEditor";
25
+ import { useI18n } from "@arronqzy/i18n/react";
26
+ import React, { useMemo, useRef, useState } from "react";
27
+ import {
28
+ Button,
29
+ Dialog,
30
+ DialogContent,
31
+ DialogDescription,
32
+ DialogFooter,
33
+ DialogHeader,
34
+ DialogTitle,
35
+ Input,
36
+ Label,
37
+ Select,
38
+ SelectContent,
39
+ SelectItem,
40
+ SelectTrigger,
41
+ SelectValue,
42
+ Switch,
43
+ Textarea,
44
+ } from "@arronqzy/ui";
45
+
46
+ type UpdateElement = (
47
+ id: string,
48
+ patch: Partial<PanelElement>,
49
+ options?: { batchId?: string; meta?: Record<string, unknown> }
50
+ ) => void;
51
+
52
+ export type PanelConfigTableSectionProps = {
53
+ element: PanelElement;
54
+ disabled?: boolean;
55
+ updateElement: UpdateElement;
56
+ /** optional list of blueprint nodes for action selectors */
57
+ blueprintNodeOptions?: { id: string; label: string }[];
58
+ };
59
+
60
+ const TRANSFORM_MODES = ["auto", "records", "matrix", "path"] as const;
61
+ const WIDGETS: TableCellWidget[] = [
62
+ "text",
63
+ "tag",
64
+ "badge",
65
+ "link",
66
+ "progress",
67
+ "image",
68
+ "boolean",
69
+ ];
70
+ function leafValueText(value: unknown): string {
71
+ if (value == null) return "";
72
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
73
+ return String(value);
74
+ }
75
+ try {
76
+ return JSON.stringify(value);
77
+ } catch {
78
+ return "";
79
+ }
80
+ }
81
+
82
+ function parseLeafValue(text: string): unknown {
83
+ const trimmed = text.trim();
84
+ if (trimmed === "") return "";
85
+ if (trimmed === "true") return true;
86
+ if (trimmed === "false") return false;
87
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
88
+ return text;
89
+ }
90
+
91
+ function booleanMapSelectValue(value: unknown): "true" | "false" {
92
+ const parsed = coerceBooleanMapTarget(value);
93
+ return parsed === false ? "false" : "true";
94
+ }
95
+
96
+ function BlueprintNodeSelect({
97
+ value,
98
+ onChange,
99
+ options,
100
+ className,
101
+ noneLabel,
102
+ }: {
103
+ value?: string;
104
+ onChange: (next: string | undefined) => void;
105
+ options: { id: string; label: string }[];
106
+ className?: string;
107
+ noneLabel: string;
108
+ }) {
109
+ const current = value?.trim() || "";
110
+ const orphan =
111
+ current && !options.some((opt) => opt.id === current) ? current : null;
112
+ return (
113
+ <Select
114
+ value={current || "__none__"}
115
+ onValueChange={(next) => onChange(next === "__none__" ? undefined : next)}
116
+ >
117
+ <SelectTrigger className={className}>
118
+ <SelectValue />
119
+ </SelectTrigger>
120
+ <SelectContent>
121
+ <SelectItem value="__none__">{noneLabel}</SelectItem>
122
+ {orphan ? <SelectItem value={orphan}>{orphan}</SelectItem> : null}
123
+ {options.map((opt) => (
124
+ <SelectItem key={opt.id} value={opt.id}>
125
+ {opt.label}
126
+ </SelectItem>
127
+ ))}
128
+ </SelectContent>
129
+ </Select>
130
+ );
131
+ }
132
+
133
+ export function PanelConfigTableSection({
134
+ element,
135
+ disabled = false,
136
+ updateElement,
137
+ blueprintNodeOptions = [],
138
+ }: PanelConfigTableSectionProps) {
139
+ const { t } = useI18n();
140
+ const table = element.table ?? {};
141
+ const columns = table.columns ?? [];
142
+ const [selectedColumnId, setSelectedColumnId] = useState<string | null>(null);
143
+ const [rowsEditorOpen, setRowsEditorOpen] = useState(false);
144
+ const [rowsEditorDraft, setRowsEditorDraft] = useState("");
145
+ const [rowsEditorError, setRowsEditorError] = useState<string | null>(null);
146
+ const [mockOpen, setMockOpen] = useState(false);
147
+ const [mockRowCount, setMockRowCount] = useState(5);
148
+ const [mockSeed, setMockSeed] = useState("");
149
+ const [mockNamePrefix, setMockNamePrefix] = useState("");
150
+ const [mockLocale, setMockLocale] = useState<TableMockLocale>("zh");
151
+ const [mockIncludeId, setMockIncludeId] = useState(true);
152
+ const [mockIncludeTimestamp, setMockIncludeTimestamp] = useState(false);
153
+ const [mockIncludeExtras, setMockIncludeExtras] = useState(true);
154
+ const [mockStatusValues, setMockStatusValues] = useState("active,idle,pending,done");
155
+ const [mockScoreMin, setMockScoreMin] = useState(0);
156
+ const [mockScoreMax, setMockScoreMax] = useState(100);
157
+
158
+ const selectedIndex = useMemo(() => {
159
+ if (columns.length === 0) return -1;
160
+ if (selectedColumnId == null) return 0;
161
+ const idx = columns.findIndex(
162
+ (col, i) => (col.id ?? `col-${i}`) === selectedColumnId || col.field === selectedColumnId
163
+ );
164
+ return idx >= 0 ? idx : 0;
165
+ }, [columns, selectedColumnId]);
166
+
167
+ const selectedColumn = selectedIndex >= 0 ? columns[selectedIndex] : null;
168
+
169
+ const booleanValueMapConflicts = useMemo(() => {
170
+ if (selectedColumn?.widget !== "boolean") return [];
171
+ return findBooleanValueMapConflicts(selectedColumn.valueMap);
172
+ }, [selectedColumn]);
173
+
174
+ const patchTable = (patch: Partial<PanelTableConfig>) => {
175
+ updateElement(element.id, {
176
+ table: { ...table, ...patch },
177
+ });
178
+ };
179
+
180
+ const patchTransform = (patch: NonNullable<PanelTableConfig["transform"]>) => {
181
+ patchTable({
182
+ transform: { ...(table.transform ?? {}), ...patch },
183
+ });
184
+ };
185
+
186
+ const patchTableStyle = (patch: NonNullable<PanelTableConfig["tableStyle"]>) => {
187
+ patchTable({
188
+ tableStyle: { ...(table.tableStyle ?? {}), ...patch },
189
+ });
190
+ };
191
+
192
+ const patchHeaderStyle = (patch: NonNullable<PanelTableConfig["headerStyle"]>) => {
193
+ patchTable({
194
+ headerStyle: { ...(table.headerStyle ?? {}), ...patch },
195
+ });
196
+ };
197
+
198
+ const setColumns = (next: TableColumnConfig[]) => {
199
+ patchTable({ columns: next });
200
+ };
201
+
202
+ const updateColumnAt = (index: number, patch: Partial<TableColumnConfig>) => {
203
+ const next = columns.map((col, i) => (i === index ? { ...col, ...patch } : col));
204
+ setColumns(next);
205
+ };
206
+
207
+ const patchWidgetProps = (index: number, patch: Partial<TableWidgetProps>) => {
208
+ const col = columns[index];
209
+ if (!col) return;
210
+ updateColumnAt(index, {
211
+ widgetProps: { ...(col.widgetProps ?? {}), ...patch },
212
+ });
213
+ };
214
+
215
+ const imageUploadRef = useRef<HTMLInputElement | null>(null);
216
+ const [imageUploadHint, setImageUploadHint] = useState("");
217
+
218
+ const uploadImageForColumn = async (index: number, file: File) => {
219
+ try {
220
+ const base64 = await new Promise<string>((resolve, reject) => {
221
+ const reader = new FileReader();
222
+ reader.onload = () => resolve(String(reader.result ?? ""));
223
+ reader.onerror = () => reject(new Error("read failed"));
224
+ reader.readAsDataURL(file);
225
+ });
226
+ patchWidgetProps(index, {
227
+ imageUrlMode: "static",
228
+ imageUrl: base64,
229
+ });
230
+ setImageUploadHint(t("panel.config.uploadWrittenBase64"));
231
+ try {
232
+ const form = new FormData();
233
+ form.append("file", file);
234
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
235
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
236
+ const data = (await resp.json()) as { url?: string };
237
+ if (data.url) {
238
+ patchWidgetProps(index, {
239
+ imageUrlMode: "static",
240
+ imageUrl: data.url,
241
+ });
242
+ setImageUploadHint(t("panel.config.uploadServerAndBase64"));
243
+ }
244
+ } catch {
245
+ setImageUploadHint(t("panel.config.uploadServerFailedKeepBase64"));
246
+ }
247
+ } catch {
248
+ setImageUploadHint(t("panel.messages.readImageFailed"));
249
+ }
250
+ };
251
+
252
+ const addColumn = () => {
253
+ const n = columns.length + 1;
254
+ const col: TableColumnConfig = {
255
+ id: `col-${Date.now().toString(36)}`,
256
+ field: `field${n}`,
257
+ title: t("panel.config.tableColumnUntitled", { n }),
258
+ width: 120,
259
+ widget: "text",
260
+ };
261
+ setColumns([...columns, col]);
262
+ setSelectedColumnId(col.id!);
263
+ };
264
+
265
+ const removeColumn = (index: number) => {
266
+ const next = columns.filter((_, i) => i !== index);
267
+ setColumns(next);
268
+ if (selectedIndex === index) {
269
+ setSelectedColumnId(next[0]?.id ?? next[0]?.field ?? null);
270
+ }
271
+ };
272
+
273
+ const moveColumn = (index: number, delta: -1 | 1) => {
274
+ const target = index + delta;
275
+ if (target < 0 || target >= columns.length) return;
276
+ const next = [...columns];
277
+ const [item] = next.splice(index, 1);
278
+ if (!item) return;
279
+ next.splice(target, 0, item);
280
+ setColumns(next);
281
+ setSelectedColumnId(item.id ?? item.field ?? `col-${target}`);
282
+ };
283
+
284
+ const fieldClass = "block space-y-1 text-[11px]";
285
+ const labelClass = "text-[11px] text-muted-foreground";
286
+ const inputClass = "h-7";
287
+
288
+ const fieldLabel = (labelKey: string, hintKey?: string) => {
289
+ const label = t(labelKey);
290
+ return (
291
+ <div className="flex min-w-0 items-center gap-1">
292
+ <Label className={labelClass}>{label}</Label>
293
+ {hintKey ? (
294
+ <ConfigHintIcon label={label}>
295
+ <div>{t(hintKey)}</div>
296
+ </ConfigHintIcon>
297
+ ) : null}
298
+ </div>
299
+ );
300
+ };
301
+
302
+ const groupTitle = (titleKey: string, hintKey?: string) => {
303
+ const title = t(titleKey);
304
+ return (
305
+ <div className="flex items-center gap-1">
306
+ <div className="text-[11px] font-semibold text-muted-foreground">{title}</div>
307
+ {hintKey ? (
308
+ <ConfigHintIcon label={title}>
309
+ <div>{t(hintKey)}</div>
310
+ </ConfigHintIcon>
311
+ ) : null}
312
+ </div>
313
+ );
314
+ };
315
+
316
+ const toPickerHex = (value: string | undefined, fallback = "#000000") =>
317
+ /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value || "") ? (value as string) : fallback;
318
+
319
+ const colorField = (
320
+ labelKey: string,
321
+ value: string,
322
+ onChange: (next: string) => void,
323
+ hintKey?: string,
324
+ placeholder = "#000000"
325
+ ) => {
326
+ const label = t(labelKey);
327
+ return (
328
+ <div className={fieldClass}>
329
+ {fieldLabel(labelKey, hintKey)}
330
+ <div className="flex items-center gap-2">
331
+ <Input
332
+ value={value}
333
+ onChange={(e) => onChange(e.target.value)}
334
+ placeholder={placeholder}
335
+ className={`${inputClass} min-w-0 flex-1`}
336
+ />
337
+ <Input
338
+ type="color"
339
+ value={toPickerHex(value, placeholder.startsWith("#") ? placeholder : "#000000")}
340
+ onChange={(e) => onChange(e.target.value)}
341
+ className="h-7 w-10 shrink-0 cursor-pointer p-1"
342
+ aria-label={t("common.colorPickerAria", { label })}
343
+ />
344
+ </div>
345
+ </div>
346
+ );
347
+ };
348
+
349
+ return (
350
+ <>
351
+ <fieldset disabled={disabled} className={`space-y-3 ${disabled ? "opacity-60" : ""}`}>
352
+ <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
353
+ {groupTitle("panel.config.groupTableData", "panel.config.groupTableDataHint")}
354
+ <label className={fieldClass} data-config-field="table.source">
355
+ {fieldLabel("panel.config.tableSource", "panel.config.tableSourceHint")}
356
+ <Input
357
+ value={table.source ?? ""}
358
+ onChange={(e) => patchTable({ source: e.target.value || undefined })}
359
+ placeholder="{scope?.list}"
360
+ className={inputClass}
361
+ />
362
+ </label>
363
+ <div className={fieldClass} data-config-field="table.rowsText">
364
+ <div className="flex items-center justify-between gap-2">
365
+ {fieldLabel("panel.config.tableRowsText", "panel.config.tableRowsTextHint")}
366
+ <div className="flex items-center gap-0.5">
367
+ <Button
368
+ type="button"
369
+ variant="ghost"
370
+ size="sm"
371
+ className="h-6 w-6 px-0"
372
+ title={t("panel.config.tableRowsEdit")}
373
+ aria-label={t("panel.config.tableRowsEdit")}
374
+ onClick={() => {
375
+ setRowsEditorDraft(table.rowsText ?? "");
376
+ setRowsEditorError(null);
377
+ setRowsEditorOpen(true);
378
+ }}
379
+ >
380
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8">
381
+ <path d="M12 20h9" />
382
+ <path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
383
+ </svg>
384
+ </Button>
385
+ <Button
386
+ type="button"
387
+ variant="ghost"
388
+ size="sm"
389
+ className="h-6 w-6 px-0"
390
+ title={t("panel.config.tableRowsMock")}
391
+ aria-label={t("panel.config.tableRowsMock")}
392
+ onClick={() => setMockOpen(true)}
393
+ >
394
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8">
395
+ <path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
396
+ <circle cx="12" cy="12" r="4" />
397
+ <path d="M5.6 5.6 7.7 7.7M16.3 16.3l2.1 2.1M16.3 7.7l2.1-2.1M5.6 18.4 7.7 16.3" />
398
+ </svg>
399
+ </Button>
400
+ </div>
401
+ </div>
402
+ <Textarea
403
+ value={table.rowsText ?? ""}
404
+ onChange={(e) => patchTable({ rowsText: e.target.value })}
405
+ placeholder='[{"name":"A"}]'
406
+ className="min-h-[140px] font-mono text-[11px]"
407
+ />
408
+ </div>
409
+ <details className="rounded-md border border-border/50 bg-muted/10 px-2 py-1.5">
410
+ <summary className="cursor-pointer text-[11px] font-medium text-muted-foreground">
411
+ {t("panel.config.tableAdvancedData")}
412
+ </summary>
413
+ <div className="mt-2 space-y-2 border-t border-border/40 pt-2">
414
+ <p className="text-[10px] text-muted-foreground">{t("panel.config.tableAdvancedDataHint")}</p>
415
+ <div className="grid grid-cols-2 gap-2">
416
+ <label className={fieldClass}>
417
+ {fieldLabel("panel.config.tableTransformMode", "panel.config.tableTransformModeHint")}
418
+ <Select
419
+ value={table.transform?.mode ?? "auto"}
420
+ onValueChange={(value) =>
421
+ patchTransform({ mode: value as (typeof TRANSFORM_MODES)[number] })
422
+ }
423
+ >
424
+ <SelectTrigger className={inputClass}>
425
+ <SelectValue />
426
+ </SelectTrigger>
427
+ <SelectContent>
428
+ {TRANSFORM_MODES.map((mode) => (
429
+ <SelectItem key={mode} value={mode}>
430
+ {t(`panel.config.tableMode.${mode}`)}
431
+ </SelectItem>
432
+ ))}
433
+ </SelectContent>
434
+ </Select>
435
+ </label>
436
+ <label className={fieldClass}>
437
+ {fieldLabel("panel.config.tableRowIdField", "panel.config.tableRowIdFieldHint")}
438
+ <Input
439
+ value={table.transform?.rowIdField ?? ""}
440
+ onChange={(e) =>
441
+ patchTransform({ rowIdField: e.target.value || undefined })
442
+ }
443
+ placeholder="id"
444
+ className={inputClass}
445
+ />
446
+ </label>
447
+ </div>
448
+ <label className={fieldClass}>
449
+ {fieldLabel("panel.config.tableTransformPath", "panel.config.tableTransformPathHint")}
450
+ <Input
451
+ value={table.transform?.path ?? ""}
452
+ onChange={(e) => patchTransform({ path: e.target.value || undefined })}
453
+ placeholder="data.list"
454
+ className={inputClass}
455
+ />
456
+ </label>
457
+ </div>
458
+ </details>
459
+ </div>
460
+
461
+ <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
462
+ <div className="flex items-center justify-between gap-2">
463
+ {groupTitle("panel.config.groupTableColumns", "panel.config.groupTableColumnsHint")}
464
+ <Button type="button" variant="outline" size="sm" className="h-7 px-2 text-[11px]" onClick={addColumn}>
465
+ {t("panel.config.tableAddColumn")}
466
+ </Button>
467
+ </div>
468
+ <div className="space-y-1.5">
469
+ {columns.map((col, index) => {
470
+ const key = col.id ?? `col-${index}`;
471
+ const active = index === selectedIndex;
472
+ return (
473
+ <div
474
+ key={key}
475
+ role="button"
476
+ tabIndex={0}
477
+ onClick={() => setSelectedColumnId(key)}
478
+ onKeyDown={(e) => {
479
+ if (e.key === "Enter" || e.key === " ") {
480
+ e.preventDefault();
481
+ setSelectedColumnId(key);
482
+ }
483
+ }}
484
+ className={`flex items-center gap-1.5 rounded-md border px-1.5 py-1.5 ${
485
+ active
486
+ ? "border-primary/55 bg-primary/5 ring-1 ring-primary/20"
487
+ : "border-border/60 hover:bg-muted/30"
488
+ }`}
489
+ >
490
+ <div className="flex shrink-0 flex-col gap-0.5">
491
+ <Button
492
+ type="button"
493
+ variant="ghost"
494
+ size="sm"
495
+ className="h-5 w-5 px-0 text-[10px]"
496
+ disabled={index === 0}
497
+ title={t("panel.config.tableMoveColumnUp")}
498
+ aria-label={t("panel.config.tableMoveColumnUp")}
499
+ onClick={(e) => {
500
+ e.stopPropagation();
501
+ moveColumn(index, -1);
502
+ }}
503
+ >
504
+ ↑
505
+ </Button>
506
+ <Button
507
+ type="button"
508
+ variant="ghost"
509
+ size="sm"
510
+ className="h-5 w-5 px-0 text-[10px]"
511
+ disabled={index >= columns.length - 1}
512
+ title={t("panel.config.tableMoveColumnDown")}
513
+ aria-label={t("panel.config.tableMoveColumnDown")}
514
+ onClick={(e) => {
515
+ e.stopPropagation();
516
+ moveColumn(index, 1);
517
+ }}
518
+ >
519
+ ↓
520
+ </Button>
521
+ </div>
522
+ <div className="min-w-0 flex-1">
523
+ <div className="truncate text-[11px] font-medium">
524
+ {col.title || col.field || key}
525
+ </div>
526
+ <div className="truncate text-[10px] text-muted-foreground">
527
+ {col.field}
528
+ {" · "}
529
+ {t(`panel.config.tableWidget.${col.widget ?? "text"}`)}
530
+ </div>
531
+ </div>
532
+ <label
533
+ className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground"
534
+ onClick={(e) => e.stopPropagation()}
535
+ >
536
+ <span className="whitespace-nowrap">{t("panel.config.tableColumnWidth")}</span>
537
+ <Input
538
+ type="number"
539
+ min={40}
540
+ max={800}
541
+ value={col.width ?? ""}
542
+ onChange={(e) => {
543
+ const n = Number(e.target.value);
544
+ updateColumnAt(index, {
545
+ width: Number.isFinite(n) && n > 0 ? n : undefined,
546
+ });
547
+ }}
548
+ className="h-6 w-14 px-1 text-[11px]"
549
+ placeholder="—"
550
+ />
551
+ </label>
552
+ <Button
553
+ type="button"
554
+ variant="ghost"
555
+ size="sm"
556
+ className="h-6 w-6 shrink-0 px-0 text-destructive"
557
+ title={t("panel.config.tableRemoveColumn")}
558
+ aria-label={t("panel.config.tableRemoveColumn")}
559
+ onClick={(e) => {
560
+ e.stopPropagation();
561
+ removeColumn(index);
562
+ }}
563
+ >
564
+ ×
565
+ </Button>
566
+ </div>
567
+ );
568
+ })}
569
+ {columns.length === 0 ? (
570
+ <div className="text-[11px] text-muted-foreground">{t("panel.config.tableNoColumns")}</div>
571
+ ) : null}
572
+ </div>
573
+ </div>
574
+
575
+ {selectedColumn && selectedIndex >= 0 ? (
576
+ <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
577
+ <div className="text-[11px] font-semibold text-muted-foreground">
578
+ {t("panel.config.groupTableColumnDetail", {
579
+ name: selectedColumn.title || selectedColumn.field,
580
+ })}
581
+ </div>
582
+ <div className="grid grid-cols-2 gap-2">
583
+ <label className={fieldClass}>
584
+ {fieldLabel("panel.config.tableColumnField", "panel.config.tableColumnFieldHint")}
585
+ <Input
586
+ value={selectedColumn.field}
587
+ onChange={(e) => updateColumnAt(selectedIndex, { field: e.target.value })}
588
+ className={inputClass}
589
+ />
590
+ </label>
591
+ <label className={fieldClass}>
592
+ {fieldLabel("panel.config.tableColumnTitle", "panel.config.tableColumnTitleHint")}
593
+ <Input
594
+ value={selectedColumn.title ?? ""}
595
+ onChange={(e) =>
596
+ updateColumnAt(selectedIndex, { title: e.target.value || undefined })
597
+ }
598
+ className={inputClass}
599
+ />
600
+ </label>
601
+ <label className={fieldClass}>
602
+ {fieldLabel("panel.config.tableColumnWidth")}
603
+ <Input
604
+ type="number"
605
+ min={40}
606
+ max={800}
607
+ value={selectedColumn.width ?? ""}
608
+ onChange={(e) => {
609
+ const n = Number(e.target.value);
610
+ updateColumnAt(selectedIndex, {
611
+ width: Number.isFinite(n) && n > 0 ? n : undefined,
612
+ });
613
+ }}
614
+ className={inputClass}
615
+ />
616
+ </label>
617
+ <label className={fieldClass}>
618
+ {fieldLabel("panel.config.tableColumnWidget", "panel.config.tableColumnWidgetHint")}
619
+ <Select
620
+ value={selectedColumn.widget ?? "text"}
621
+ onValueChange={(value) =>
622
+ updateColumnAt(selectedIndex, { widget: value as TableCellWidget })
623
+ }
624
+ >
625
+ <SelectTrigger className={inputClass}>
626
+ <SelectValue />
627
+ </SelectTrigger>
628
+ <SelectContent>
629
+ {WIDGETS.map((widget) => (
630
+ <SelectItem key={widget} value={widget}>
631
+ {t(`panel.config.tableWidget.${widget}`)}
632
+ </SelectItem>
633
+ ))}
634
+ </SelectContent>
635
+ </Select>
636
+ </label>
637
+ </div>
638
+
639
+ <div className="space-y-2 rounded-md border border-border/60 bg-muted/15 p-2">
640
+ <div className="flex items-center justify-between gap-2">
641
+ <div className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground">
642
+ <span>{t("panel.config.tableTooltipEnabled")}</span>
643
+ <ConfigHintIcon label={t("panel.config.tableTooltipEnabled")}>
644
+ <div>{t("panel.config.tableTooltipEnabledHint")}</div>
645
+ </ConfigHintIcon>
646
+ </div>
647
+ <Switch
648
+ checked={Boolean(selectedColumn.tooltipEnabled)}
649
+ onCheckedChange={(checked) =>
650
+ updateColumnAt(selectedIndex, { tooltipEnabled: checked || undefined })
651
+ }
652
+ />
653
+ </div>
654
+ {selectedColumn.tooltipEnabled ? (
655
+ <>
656
+ <label className={fieldClass}>
657
+ {fieldLabel("panel.config.tableTooltipPlacementLabel")}
658
+ <Select
659
+ value={selectedColumn.tooltipPlacement ?? "top"}
660
+ onValueChange={(value) =>
661
+ updateColumnAt(selectedIndex, {
662
+ tooltipPlacement: value as TableTooltipPlacement,
663
+ })
664
+ }
665
+ >
666
+ <SelectTrigger className={inputClass}>
667
+ <SelectValue />
668
+ </SelectTrigger>
669
+ <SelectContent>
670
+ {(["top", "right", "bottom", "left"] as TableTooltipPlacement[]).map(
671
+ (side) => (
672
+ <SelectItem key={side} value={side}>
673
+ {t(`panel.config.tableTooltipPlacement.${side}`)}
674
+ </SelectItem>
675
+ )
676
+ )}
677
+ </SelectContent>
678
+ </Select>
679
+ </label>
680
+ <label className={fieldClass}>
681
+ {fieldLabel(
682
+ "panel.config.tableTooltipTemplate",
683
+ "panel.config.tableTooltipTemplateHint"
684
+ )}
685
+ <Input
686
+ value={selectedColumn.tooltipTemplate ?? ""}
687
+ onChange={(e) =>
688
+ updateColumnAt(selectedIndex, {
689
+ tooltipTemplate: e.target.value.trim() ? e.target.value : undefined,
690
+ })
691
+ }
692
+ placeholder="{current}"
693
+ className={inputClass}
694
+ />
695
+ </label>
696
+ </>
697
+ ) : null}
698
+ </div>
699
+
700
+ <div className="space-y-2 rounded-md border border-border/60 bg-muted/15 p-2">
701
+ <div className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground">
702
+ <span>{t("panel.config.tableWidgetProps")}</span>
703
+ <ConfigHintIcon label={t("panel.config.tableWidgetProps")}>
704
+ <div>{t("panel.config.tableWidgetPropsHint")}</div>
705
+ </ConfigHintIcon>
706
+ </div>
707
+
708
+ {(selectedColumn.widget ?? "text") === "text" ? (
709
+ <>
710
+ <div className="grid grid-cols-2 gap-2">
711
+ <label className={fieldClass}>
712
+ {fieldLabel("panel.config.tableTextFontSize")}
713
+ <Input
714
+ type="number"
715
+ min={8}
716
+ max={72}
717
+ value={selectedColumn.widgetProps?.textStyle?.fontSize ?? ""}
718
+ onChange={(e) => {
719
+ const n = Number(e.target.value);
720
+ patchWidgetProps(selectedIndex, {
721
+ textStyle: {
722
+ ...(selectedColumn.widgetProps?.textStyle ?? {}),
723
+ fontSize: Number.isFinite(n) && n > 0 ? n : undefined,
724
+ },
725
+ });
726
+ }}
727
+ placeholder="12"
728
+ className={inputClass}
729
+ />
730
+ </label>
731
+ <label className={fieldClass}>
732
+ {fieldLabel("panel.config.tableTextFontWeight")}
733
+ <Select
734
+ value={String(selectedColumn.widgetProps?.textStyle?.fontWeight ?? "normal")}
735
+ onValueChange={(value) =>
736
+ patchWidgetProps(selectedIndex, {
737
+ textStyle: {
738
+ ...(selectedColumn.widgetProps?.textStyle ?? {}),
739
+ fontWeight: value === "normal" ? undefined : value,
740
+ },
741
+ })
742
+ }
743
+ >
744
+ <SelectTrigger className={inputClass}>
745
+ <SelectValue />
746
+ </SelectTrigger>
747
+ <SelectContent>
748
+ <SelectItem value="normal">{t("panel.config.tableTextWeightNormal")}</SelectItem>
749
+ <SelectItem value="500">500</SelectItem>
750
+ <SelectItem value="600">600</SelectItem>
751
+ <SelectItem value="700">{t("panel.config.tableTextWeightBold")}</SelectItem>
752
+ </SelectContent>
753
+ </Select>
754
+ </label>
755
+ {colorField(
756
+ "panel.config.tableTextColor",
757
+ selectedColumn.widgetProps?.textStyle?.color ?? "",
758
+ (next) =>
759
+ patchWidgetProps(selectedIndex, {
760
+ textStyle: {
761
+ ...(selectedColumn.widgetProps?.textStyle ?? {}),
762
+ color: next || undefined,
763
+ },
764
+ }),
765
+ undefined,
766
+ "#111827"
767
+ )}
768
+ <label className={fieldClass}>
769
+ {fieldLabel("panel.config.tableTextFontFamily")}
770
+ <Input
771
+ value={selectedColumn.widgetProps?.textStyle?.fontFamily ?? ""}
772
+ onChange={(e) =>
773
+ patchWidgetProps(selectedIndex, {
774
+ textStyle: {
775
+ ...(selectedColumn.widgetProps?.textStyle ?? {}),
776
+ fontFamily: e.target.value || undefined,
777
+ },
778
+ })
779
+ }
780
+ placeholder="inherit"
781
+ className={inputClass}
782
+ />
783
+ </label>
784
+ <label className={fieldClass}>
785
+ {fieldLabel("panel.config.tableTextFontStyle")}
786
+ <Select
787
+ value={selectedColumn.widgetProps?.textStyle?.fontStyle ?? "normal"}
788
+ onValueChange={(value) =>
789
+ patchWidgetProps(selectedIndex, {
790
+ textStyle: {
791
+ ...(selectedColumn.widgetProps?.textStyle ?? {}),
792
+ fontStyle: value as "normal" | "italic",
793
+ },
794
+ })
795
+ }
796
+ >
797
+ <SelectTrigger className={inputClass}>
798
+ <SelectValue />
799
+ </SelectTrigger>
800
+ <SelectContent>
801
+ <SelectItem value="normal">{t("panel.config.tableTextStyleNormal")}</SelectItem>
802
+ <SelectItem value="italic">{t("panel.config.tableTextStyleItalic")}</SelectItem>
803
+ </SelectContent>
804
+ </Select>
805
+ </label>
806
+ <label className={fieldClass}>
807
+ {fieldLabel("panel.config.tableTextDecoration")}
808
+ <Select
809
+ value={selectedColumn.widgetProps?.textStyle?.textDecoration ?? "none"}
810
+ onValueChange={(value) =>
811
+ patchWidgetProps(selectedIndex, {
812
+ textStyle: {
813
+ ...(selectedColumn.widgetProps?.textStyle ?? {}),
814
+ textDecoration: value as "none" | "underline" | "line-through",
815
+ },
816
+ })
817
+ }
818
+ >
819
+ <SelectTrigger className={inputClass}>
820
+ <SelectValue />
821
+ </SelectTrigger>
822
+ <SelectContent>
823
+ <SelectItem value="none">{t("panel.config.tableTextDecoNone")}</SelectItem>
824
+ <SelectItem value="underline">{t("panel.config.tableTextDecoUnderline")}</SelectItem>
825
+ <SelectItem value="line-through">{t("panel.config.tableTextDecoLineThrough")}</SelectItem>
826
+ </SelectContent>
827
+ </Select>
828
+ </label>
829
+ <label className={fieldClass}>
830
+ {fieldLabel("panel.config.tableTextOverflow", "panel.config.tableTextOverflowHint")}
831
+ <Select
832
+ value={selectedColumn.widgetProps?.textStyle?.overflow ?? "ellipsis"}
833
+ onValueChange={(value) =>
834
+ patchWidgetProps(selectedIndex, {
835
+ textStyle: {
836
+ ...(selectedColumn.widgetProps?.textStyle ?? {}),
837
+ overflow: value as "ellipsis" | "wrap",
838
+ },
839
+ })
840
+ }
841
+ >
842
+ <SelectTrigger className={inputClass}>
843
+ <SelectValue />
844
+ </SelectTrigger>
845
+ <SelectContent>
846
+ <SelectItem value="ellipsis">{t("panel.config.tableTextOverflowEllipsis")}</SelectItem>
847
+ <SelectItem value="wrap">{t("panel.config.tableTextOverflowWrap")}</SelectItem>
848
+ </SelectContent>
849
+ </Select>
850
+ </label>
851
+ </div>
852
+ <label className={fieldClass}>
853
+ {fieldLabel("panel.config.tableActionOnClick", "panel.config.tableActionBlueprintHint")}
854
+ <BlueprintNodeSelect
855
+ value={selectedColumn.widgetProps?.actions?.onClickBlueprintNodeId}
856
+ options={blueprintNodeOptions}
857
+ className={inputClass}
858
+ noneLabel={t("panel.config.tableActionBlueprintNone")}
859
+ onChange={(next) =>
860
+ patchWidgetProps(selectedIndex, {
861
+ actions: {
862
+ ...(selectedColumn.widgetProps?.actions ?? {}),
863
+ onClickBlueprintNodeId: next,
864
+ },
865
+ })
866
+ }
867
+ />
868
+ </label>
869
+ </>
870
+ ) : null}
871
+
872
+ {selectedColumn.widget === "image" ? (
873
+ <>
874
+ <label className={fieldClass}>
875
+ {fieldLabel("panel.config.tableImageUrlMode", "panel.config.tableImageUrlModeHint")}
876
+ <Select
877
+ value={selectedColumn.widgetProps?.imageUrlMode ?? "field"}
878
+ onValueChange={(value) =>
879
+ patchWidgetProps(selectedIndex, {
880
+ imageUrlMode: value as NonNullable<TableWidgetProps["imageUrlMode"]>,
881
+ })
882
+ }
883
+ >
884
+ <SelectTrigger className={inputClass}>
885
+ <SelectValue />
886
+ </SelectTrigger>
887
+ <SelectContent>
888
+ <SelectItem value="field">
889
+ {t("panel.config.tableImageUrlModeField")}
890
+ </SelectItem>
891
+ <SelectItem value="static">
892
+ {t("panel.config.tableImageUrlModeStatic")}
893
+ </SelectItem>
894
+ <SelectItem value="prefix">
895
+ {t("panel.config.tableImageUrlModePrefix")}
896
+ </SelectItem>
897
+ <SelectItem value="template">
898
+ {t("panel.config.tableImageUrlModeTemplate")}
899
+ </SelectItem>
900
+ </SelectContent>
901
+ </Select>
902
+ </label>
903
+
904
+ {(selectedColumn.widgetProps?.imageUrlMode ?? "field") === "field" ||
905
+ (selectedColumn.widgetProps?.imageUrlMode ?? "field") === "prefix" ? (
906
+ <label className={fieldClass}>
907
+ {fieldLabel(
908
+ "panel.config.tableImageUrlField",
909
+ "panel.config.tableImageUrlFieldHint"
910
+ )}
911
+ <Input
912
+ value={selectedColumn.widgetProps?.imageUrlField ?? ""}
913
+ onChange={(e) =>
914
+ patchWidgetProps(selectedIndex, {
915
+ imageUrlField: e.target.value || undefined,
916
+ })
917
+ }
918
+ placeholder={selectedColumn.field}
919
+ className={inputClass}
920
+ />
921
+ </label>
922
+ ) : null}
923
+
924
+ {(selectedColumn.widgetProps?.imageUrlMode ?? "field") === "static" ? (
925
+ <div className="space-y-1.5">
926
+ <label className={fieldClass}>
927
+ {fieldLabel("panel.config.tableImageUrl", "panel.config.tableImageUrlHint")}
928
+ <Input
929
+ value={selectedColumn.widgetProps?.imageUrl ?? ""}
930
+ onChange={(e) =>
931
+ patchWidgetProps(selectedIndex, {
932
+ imageUrl: e.target.value || undefined,
933
+ })
934
+ }
935
+ placeholder="{scope?.avatar} / https://…"
936
+ className={inputClass}
937
+ />
938
+ </label>
939
+ <div className="flex flex-wrap items-center gap-2">
940
+ <Button
941
+ type="button"
942
+ variant="outline"
943
+ size="sm"
944
+ className="h-7 px-2 text-[11px]"
945
+ onClick={() => imageUploadRef.current?.click()}
946
+ >
947
+ {t("panel.config.tableImageUpload")}
948
+ </Button>
949
+ <input
950
+ ref={imageUploadRef}
951
+ type="file"
952
+ accept="image/*"
953
+ className="hidden"
954
+ onChange={(e) => {
955
+ const file = e.target.files?.[0];
956
+ e.target.value = "";
957
+ if (file) void uploadImageForColumn(selectedIndex, file);
958
+ }}
959
+ />
960
+ {imageUploadHint ? (
961
+ <span className="text-[11px] text-muted-foreground">{imageUploadHint}</span>
962
+ ) : null}
963
+ </div>
964
+ </div>
965
+ ) : null}
966
+
967
+ {(selectedColumn.widgetProps?.imageUrlMode ?? "field") === "prefix" ? (
968
+ <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
969
+ <label className={fieldClass}>
970
+ {fieldLabel(
971
+ "panel.config.tableImageUrlPrefix",
972
+ "panel.config.tableImageUrlPrefixHint"
973
+ )}
974
+ <Input
975
+ value={selectedColumn.widgetProps?.imageUrlPrefix ?? ""}
976
+ onChange={(e) =>
977
+ patchWidgetProps(selectedIndex, {
978
+ imageUrlPrefix: e.target.value || undefined,
979
+ })
980
+ }
981
+ placeholder="https://cdn.example.com/ 或 {scope?.cdn}/"
982
+ className={inputClass}
983
+ />
984
+ </label>
985
+ <label className={fieldClass}>
986
+ {fieldLabel(
987
+ "panel.config.tableImageUrlSuffix",
988
+ "panel.config.tableImageUrlSuffixHint"
989
+ )}
990
+ <Input
991
+ value={selectedColumn.widgetProps?.imageUrlSuffix ?? ""}
992
+ onChange={(e) =>
993
+ patchWidgetProps(selectedIndex, {
994
+ imageUrlSuffix: e.target.value || undefined,
995
+ })
996
+ }
997
+ placeholder=".png"
998
+ className={inputClass}
999
+ />
1000
+ </label>
1001
+ </div>
1002
+ ) : null}
1003
+
1004
+ {(selectedColumn.widgetProps?.imageUrlMode ?? "field") === "template" ? (
1005
+ <label className={fieldClass}>
1006
+ {fieldLabel(
1007
+ "panel.config.tableImageUrlTemplate",
1008
+ "panel.config.tableImageUrlTemplateHint"
1009
+ )}
1010
+ <Input
1011
+ value={selectedColumn.widgetProps?.imageUrlTemplate ?? ""}
1012
+ onChange={(e) =>
1013
+ patchWidgetProps(selectedIndex, {
1014
+ imageUrlTemplate: e.target.value || undefined,
1015
+ })
1016
+ }
1017
+ placeholder="https://cdn/{row.id}.png"
1018
+ className={inputClass}
1019
+ />
1020
+ </label>
1021
+ ) : null}
1022
+
1023
+ <div className="grid grid-cols-3 gap-1.5">
1024
+ <label className={fieldClass}>
1025
+ {fieldLabel("panel.config.tableImageWidth")}
1026
+ <Input
1027
+ type="number"
1028
+ min={12}
1029
+ max={240}
1030
+ value={selectedColumn.widgetProps?.imageWidth ?? 28}
1031
+ onChange={(e) =>
1032
+ patchWidgetProps(selectedIndex, {
1033
+ imageWidth: Math.max(12, Math.min(240, Number(e.target.value) || 28)),
1034
+ })
1035
+ }
1036
+ className={inputClass}
1037
+ />
1038
+ </label>
1039
+ <label className={fieldClass}>
1040
+ {fieldLabel("panel.config.tableImageHeight")}
1041
+ <Input
1042
+ type="number"
1043
+ min={12}
1044
+ max={240}
1045
+ value={selectedColumn.widgetProps?.imageHeight ?? 28}
1046
+ onChange={(e) =>
1047
+ patchWidgetProps(selectedIndex, {
1048
+ imageHeight: Math.max(12, Math.min(240, Number(e.target.value) || 28)),
1049
+ })
1050
+ }
1051
+ className={inputClass}
1052
+ />
1053
+ </label>
1054
+ <label className={fieldClass}>
1055
+ {fieldLabel("panel.config.tableImageObjectFit")}
1056
+ <Select
1057
+ value={selectedColumn.widgetProps?.imageObjectFit ?? "cover"}
1058
+ onValueChange={(value) =>
1059
+ patchWidgetProps(selectedIndex, {
1060
+ imageObjectFit: value as NonNullable<
1061
+ TableWidgetProps["imageObjectFit"]
1062
+ >,
1063
+ })
1064
+ }
1065
+ >
1066
+ <SelectTrigger className={inputClass}>
1067
+ <SelectValue />
1068
+ </SelectTrigger>
1069
+ <SelectContent>
1070
+ <SelectItem value="cover">
1071
+ {t("panel.config.tableImageFitCover")}
1072
+ </SelectItem>
1073
+ <SelectItem value="contain">
1074
+ {t("panel.config.tableImageFitContain")}
1075
+ </SelectItem>
1076
+ <SelectItem value="fill">
1077
+ {t("panel.config.tableImageFitFill")}
1078
+ </SelectItem>
1079
+ </SelectContent>
1080
+ </Select>
1081
+ </label>
1082
+ </div>
1083
+ </>
1084
+ ) : null}
1085
+
1086
+
1087
+ {selectedColumn.widget === "link" ? (
1088
+ <>
1089
+ <label className={fieldClass}>
1090
+ {fieldLabel("panel.config.tableLinkHrefField", "panel.config.tableLinkHrefFieldHint")}
1091
+ <Input
1092
+ value={selectedColumn.widgetProps?.hrefField ?? ""}
1093
+ onChange={(e) =>
1094
+ patchWidgetProps(selectedIndex, {
1095
+ hrefField: e.target.value || undefined,
1096
+ })
1097
+ }
1098
+ placeholder={selectedColumn.field}
1099
+ className={inputClass}
1100
+ />
1101
+ </label>
1102
+ <label className={fieldClass}>
1103
+ {fieldLabel(
1104
+ "panel.config.tableLinkHrefTemplate",
1105
+ "panel.config.tableLinkHrefTemplateHint"
1106
+ )}
1107
+ <Input
1108
+ value={selectedColumn.widgetProps?.hrefTemplate ?? ""}
1109
+ onChange={(e) =>
1110
+ patchWidgetProps(selectedIndex, {
1111
+ hrefTemplate: e.target.value || undefined,
1112
+ })
1113
+ }
1114
+ placeholder="/detail/{row.id}"
1115
+ className={inputClass}
1116
+ />
1117
+ </label>
1118
+ <div className="space-y-1.5">
1119
+ <div className="flex items-center justify-between gap-2">
1120
+ <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
1121
+ <span>{t("panel.config.tableHrefRules")}</span>
1122
+ <ConfigHintIcon label={t("panel.config.tableHrefRules")}>
1123
+ <div>{t("panel.config.tableHrefRulesHint")}</div>
1124
+ </ConfigHintIcon>
1125
+ </div>
1126
+ <Button
1127
+ type="button"
1128
+ variant="outline"
1129
+ size="sm"
1130
+ className="h-7 px-2 text-[11px]"
1131
+ onClick={() => {
1132
+ const rule: TableHrefRule = {
1133
+ when: createDefaultCondition(),
1134
+ href: "",
1135
+ };
1136
+ patchWidgetProps(selectedIndex, {
1137
+ hrefRules: [...(selectedColumn.widgetProps?.hrefRules ?? []), rule],
1138
+ });
1139
+ }}
1140
+ >
1141
+ {t("panel.config.tableAddHrefRule")}
1142
+ </Button>
1143
+ </div>
1144
+ {(selectedColumn.widgetProps?.hrefRules ?? []).map((rule, ruleIndex) => (
1145
+ <div
1146
+ key={`href-${ruleIndex}`}
1147
+ className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2"
1148
+ >
1149
+ <div className="flex items-center justify-between">
1150
+ <span className="text-[11px] text-muted-foreground">
1151
+ {t("panel.config.tableHrefRuleItem", { n: ruleIndex + 1 })}
1152
+ </span>
1153
+ <Button
1154
+ type="button"
1155
+ variant="ghost"
1156
+ size="sm"
1157
+ className="h-6 px-1.5 text-destructive"
1158
+ onClick={() => {
1159
+ const next = (selectedColumn.widgetProps?.hrefRules ?? []).filter(
1160
+ (_, i) => i !== ruleIndex
1161
+ );
1162
+ patchWidgetProps(selectedIndex, {
1163
+ hrefRules: next.length ? next : undefined,
1164
+ });
1165
+ }}
1166
+ >
1167
+ ×
1168
+ </Button>
1169
+ </div>
1170
+ <ConditionEditor
1171
+ value={rule.when}
1172
+ showField={false}
1173
+ fieldClass={fieldClass}
1174
+ inputClass={inputClass}
1175
+ onChange={(when) => {
1176
+ const next = [...(selectedColumn.widgetProps?.hrefRules ?? [])];
1177
+ next[ruleIndex] = { ...rule, when };
1178
+ patchWidgetProps(selectedIndex, { hrefRules: next });
1179
+ }}
1180
+ />
1181
+ <label className={fieldClass}>
1182
+ {fieldLabel("panel.config.tableHrefRuleHref")}
1183
+ <Input
1184
+ value={rule.href}
1185
+ onChange={(e) => {
1186
+ const next = [...(selectedColumn.widgetProps?.hrefRules ?? [])];
1187
+ next[ruleIndex] = { ...rule, href: e.target.value };
1188
+ patchWidgetProps(selectedIndex, { hrefRules: next });
1189
+ }}
1190
+ placeholder="/x/{row.id}"
1191
+ className={inputClass}
1192
+ />
1193
+ </label>
1194
+ </div>
1195
+ ))}
1196
+ </div>
1197
+ <label className="flex items-center gap-2 text-[11px]">
1198
+ <Switch
1199
+ checked={selectedColumn.widgetProps?.openInNewTab !== false}
1200
+ onCheckedChange={(checked) =>
1201
+ patchWidgetProps(selectedIndex, { openInNewTab: checked })
1202
+ }
1203
+ />
1204
+ <span>{t("panel.config.tableLinkOpenNewTab")}</span>
1205
+ </label>
1206
+ <label className={fieldClass}>
1207
+ {fieldLabel("panel.config.tableActionOnClick", "panel.config.tableActionBlueprintHint")}
1208
+ <BlueprintNodeSelect
1209
+ value={selectedColumn.widgetProps?.actions?.onClickBlueprintNodeId}
1210
+ options={blueprintNodeOptions}
1211
+ className={inputClass}
1212
+ noneLabel={t("panel.config.tableActionBlueprintNone")}
1213
+ onChange={(next) =>
1214
+ patchWidgetProps(selectedIndex, {
1215
+ actions: {
1216
+ ...(selectedColumn.widgetProps?.actions ?? {}),
1217
+ onClickBlueprintNodeId: next,
1218
+ },
1219
+ })
1220
+ }
1221
+ />
1222
+ </label>
1223
+ </>
1224
+ ) : null}
1225
+
1226
+ {selectedColumn.widget === "progress" ? (
1227
+ <>
1228
+ <label className={fieldClass}>
1229
+ {fieldLabel("panel.config.tableProgressMode", "panel.config.tableProgressModeHint")}
1230
+ <Select
1231
+ value={selectedColumn.widgetProps?.progressMode ?? "field"}
1232
+ onValueChange={(value) =>
1233
+ patchWidgetProps(selectedIndex, {
1234
+ progressMode: value as NonNullable<TableWidgetProps["progressMode"]>,
1235
+ })
1236
+ }
1237
+ >
1238
+ <SelectTrigger className={inputClass}>
1239
+ <SelectValue />
1240
+ </SelectTrigger>
1241
+ <SelectContent>
1242
+ <SelectItem value="field">{t("panel.config.tableProgressModeField")}</SelectItem>
1243
+ <SelectItem value="static">{t("panel.config.tableProgressModeStatic")}</SelectItem>
1244
+ <SelectItem value="rules">{t("panel.config.tableProgressModeRules")}</SelectItem>
1245
+ </SelectContent>
1246
+ </Select>
1247
+ </label>
1248
+ {(selectedColumn.widgetProps?.progressMode ?? "field") === "field" ? (
1249
+ <label className={fieldClass}>
1250
+ {fieldLabel("panel.config.tableProgressField", "panel.config.tableProgressFieldHint")}
1251
+ <Input
1252
+ value={selectedColumn.widgetProps?.progressField ?? ""}
1253
+ onChange={(e) =>
1254
+ patchWidgetProps(selectedIndex, {
1255
+ progressField: e.target.value || undefined,
1256
+ })
1257
+ }
1258
+ placeholder={selectedColumn.field}
1259
+ className={inputClass}
1260
+ />
1261
+ </label>
1262
+ ) : null}
1263
+ {(selectedColumn.widgetProps?.progressMode ?? "field") === "static" ? (
1264
+ <label className={fieldClass}>
1265
+ {fieldLabel("panel.config.tableProgressStatic", "panel.config.tableProgressStaticHint")}
1266
+ <Input
1267
+ value={String(selectedColumn.widgetProps?.progressStatic ?? "")}
1268
+ onChange={(e) =>
1269
+ patchWidgetProps(selectedIndex, {
1270
+ progressStatic: e.target.value || undefined,
1271
+ })
1272
+ }
1273
+ placeholder="80 或 {scope?.pct}"
1274
+ className={inputClass}
1275
+ />
1276
+ </label>
1277
+ ) : null}
1278
+ {(selectedColumn.widgetProps?.progressMode ?? "field") === "rules" ? (
1279
+ <div className="space-y-1.5">
1280
+ <div className="flex items-center justify-between gap-2">
1281
+ <span className="text-[11px] text-muted-foreground">
1282
+ {t("panel.config.tableProgressRules")}
1283
+ </span>
1284
+ <Button
1285
+ type="button"
1286
+ variant="outline"
1287
+ size="sm"
1288
+ className="h-7 px-2 text-[11px]"
1289
+ onClick={() => {
1290
+ const rule: TableProgressRule = {
1291
+ when: createDefaultCondition(),
1292
+ value: 0,
1293
+ };
1294
+ patchWidgetProps(selectedIndex, {
1295
+ progressRules: [
1296
+ ...(selectedColumn.widgetProps?.progressRules ?? []),
1297
+ rule,
1298
+ ],
1299
+ });
1300
+ }}
1301
+ >
1302
+ {t("panel.config.tableAddProgressRule")}
1303
+ </Button>
1304
+ </div>
1305
+ {(selectedColumn.widgetProps?.progressRules ?? []).map((rule, ruleIndex) => (
1306
+ <div
1307
+ key={`pr-${ruleIndex}`}
1308
+ className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2"
1309
+ >
1310
+ <div className="flex justify-between">
1311
+ <span className="text-[11px] text-muted-foreground">
1312
+ {t("panel.config.tableProgressRuleItem", { n: ruleIndex + 1 })}
1313
+ </span>
1314
+ <Button
1315
+ type="button"
1316
+ variant="ghost"
1317
+ size="sm"
1318
+ className="h-6 px-1.5 text-destructive"
1319
+ onClick={() => {
1320
+ const next = (selectedColumn.widgetProps?.progressRules ?? []).filter(
1321
+ (_, i) => i !== ruleIndex
1322
+ );
1323
+ patchWidgetProps(selectedIndex, {
1324
+ progressRules: next.length ? next : undefined,
1325
+ });
1326
+ }}
1327
+ >
1328
+ ×
1329
+ </Button>
1330
+ </div>
1331
+ <ConditionEditor
1332
+ value={rule.when}
1333
+ showField={false}
1334
+ fieldClass={fieldClass}
1335
+ inputClass={inputClass}
1336
+ onChange={(when) => {
1337
+ const next = [...(selectedColumn.widgetProps?.progressRules ?? [])];
1338
+ next[ruleIndex] = { ...rule, when };
1339
+ patchWidgetProps(selectedIndex, { progressRules: next });
1340
+ }}
1341
+ />
1342
+ <label className={fieldClass}>
1343
+ {fieldLabel("panel.config.tableProgressRuleValue")}
1344
+ <Input
1345
+ value={String(rule.value ?? "")}
1346
+ onChange={(e) => {
1347
+ const next = [...(selectedColumn.widgetProps?.progressRules ?? [])];
1348
+ next[ruleIndex] = {
1349
+ ...rule,
1350
+ value: parseLeafValue(e.target.value) as number | string,
1351
+ };
1352
+ patchWidgetProps(selectedIndex, { progressRules: next });
1353
+ }}
1354
+ className={inputClass}
1355
+ />
1356
+ </label>
1357
+ </div>
1358
+ ))}
1359
+ </div>
1360
+ ) : null}
1361
+ <label className={fieldClass}>
1362
+ {fieldLabel("panel.config.tableProgressDisplay", "panel.config.tableProgressDisplayHint")}
1363
+ <Select
1364
+ value={
1365
+ selectedColumn.widgetProps?.progressDisplay ??
1366
+ (selectedColumn.widgetProps?.showLabel === false ? "bar" : "barLabel")
1367
+ }
1368
+ onValueChange={(value) =>
1369
+ patchWidgetProps(selectedIndex, {
1370
+ progressDisplay: value as NonNullable<TableWidgetProps["progressDisplay"]>,
1371
+ showLabel: undefined,
1372
+ })
1373
+ }
1374
+ >
1375
+ <SelectTrigger className={inputClass}>
1376
+ <SelectValue />
1377
+ </SelectTrigger>
1378
+ <SelectContent>
1379
+ <SelectItem value="barLabel">{t("panel.config.tableProgressDisplayBarLabel")}</SelectItem>
1380
+ <SelectItem value="bar">{t("panel.config.tableProgressDisplayBar")}</SelectItem>
1381
+ <SelectItem value="label">{t("panel.config.tableProgressDisplayLabel")}</SelectItem>
1382
+ <SelectItem value="circle">{t("panel.config.tableProgressDisplayCircle")}</SelectItem>
1383
+ </SelectContent>
1384
+ </Select>
1385
+ </label>
1386
+ <div className="grid grid-cols-2 gap-2">
1387
+ <label className={fieldClass}>
1388
+ {fieldLabel("panel.config.tableProgressMax")}
1389
+ <Input
1390
+ type="number"
1391
+ min={1}
1392
+ value={selectedColumn.widgetProps?.max ?? 100}
1393
+ onChange={(e) =>
1394
+ patchWidgetProps(selectedIndex, {
1395
+ max: Math.max(1, Number(e.target.value) || 100),
1396
+ })
1397
+ }
1398
+ className={inputClass}
1399
+ />
1400
+ </label>
1401
+ {(selectedColumn.widgetProps?.progressDisplay ?? "barLabel") === "circle" ? (
1402
+ <label className={fieldClass}>
1403
+ {fieldLabel("panel.config.tableProgressSize")}
1404
+ <Input
1405
+ type="number"
1406
+ min={16}
1407
+ max={64}
1408
+ value={selectedColumn.widgetProps?.progressSize ?? 28}
1409
+ onChange={(e) =>
1410
+ patchWidgetProps(selectedIndex, {
1411
+ progressSize: Math.max(16, Math.min(64, Number(e.target.value) || 28)),
1412
+ })
1413
+ }
1414
+ className={inputClass}
1415
+ />
1416
+ </label>
1417
+ ) : (
1418
+ <div />
1419
+ )}
1420
+ </div>
1421
+ </>
1422
+ ) : null}
1423
+
1424
+ {selectedColumn.widget === "tag" || selectedColumn.widget === "badge" ? (
1425
+ <>
1426
+ {colorField(
1427
+ "panel.config.tableTagColor",
1428
+ selectedColumn.widgetProps?.color ?? "",
1429
+ (next) =>
1430
+ patchWidgetProps(selectedIndex, {
1431
+ color: next || undefined,
1432
+ }),
1433
+ "panel.config.tableTagColorHint",
1434
+ "#3b82f6"
1435
+ )}
1436
+ <div className="space-y-1.5">
1437
+ <div className="flex items-center justify-between gap-2">
1438
+ <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
1439
+ <span>{t("panel.config.tableColorMap")}</span>
1440
+ <ConfigHintIcon label={t("panel.config.tableColorMap")}>
1441
+ <div>{t("panel.config.tableColorMapHint")}</div>
1442
+ </ConfigHintIcon>
1443
+ </div>
1444
+ <Button
1445
+ type="button"
1446
+ variant="outline"
1447
+ size="sm"
1448
+ className="h-7 px-2 text-[11px]"
1449
+ onClick={() => {
1450
+ const entry: TableColorMapEntry = { value: "", color: "#3b82f6" };
1451
+ patchWidgetProps(selectedIndex, {
1452
+ colorMapEntries: [
1453
+ ...(selectedColumn.widgetProps?.colorMapEntries ?? []),
1454
+ entry,
1455
+ ],
1456
+ });
1457
+ }}
1458
+ >
1459
+ {t("panel.config.tableAddColorMap")}
1460
+ </Button>
1461
+ </div>
1462
+ {(selectedColumn.widgetProps?.colorMapEntries ?? []).map((entry, entryIndex) => (
1463
+ <div
1464
+ key={`cm-${entryIndex}`}
1465
+ className="grid grid-cols-[1fr_1fr_auto] items-end gap-1.5 rounded-md border border-border/60 bg-muted/20 p-2"
1466
+ >
1467
+ <label className={fieldClass}>
1468
+ {fieldLabel("panel.config.tableColorMapValue")}
1469
+ <Input
1470
+ value={entry.value}
1471
+ onChange={(e) => {
1472
+ const next = [...(selectedColumn.widgetProps?.colorMapEntries ?? [])];
1473
+ next[entryIndex] = { ...entry, value: e.target.value };
1474
+ patchWidgetProps(selectedIndex, { colorMapEntries: next });
1475
+ }}
1476
+ placeholder="ok"
1477
+ className={inputClass}
1478
+ />
1479
+ </label>
1480
+ {colorField(
1481
+ "panel.config.tableColorMapColor",
1482
+ entry.color,
1483
+ (nextColor) => {
1484
+ const next = [...(selectedColumn.widgetProps?.colorMapEntries ?? [])];
1485
+ next[entryIndex] = { ...entry, color: nextColor };
1486
+ patchWidgetProps(selectedIndex, { colorMapEntries: next });
1487
+ },
1488
+ undefined,
1489
+ "#3b82f6"
1490
+ )}
1491
+ <Button
1492
+ type="button"
1493
+ variant="ghost"
1494
+ size="sm"
1495
+ className="h-7 px-1.5 text-destructive"
1496
+ onClick={() => {
1497
+ const next = (selectedColumn.widgetProps?.colorMapEntries ?? []).filter(
1498
+ (_, i) => i !== entryIndex
1499
+ );
1500
+ patchWidgetProps(selectedIndex, {
1501
+ colorMapEntries: next.length ? next : undefined,
1502
+ });
1503
+ }}
1504
+ >
1505
+ ×
1506
+ </Button>
1507
+ </div>
1508
+ ))}
1509
+ </div>
1510
+ </>
1511
+ ) : null}
1512
+
1513
+ {selectedColumn.widget === "boolean" ? (
1514
+ <>
1515
+ <div className="grid grid-cols-2 gap-2">
1516
+ <label className={fieldClass}>
1517
+ {fieldLabel("panel.config.tableBooleanTrueLabel")}
1518
+ <Input
1519
+ value={selectedColumn.widgetProps?.trueLabel ?? ""}
1520
+ onChange={(e) =>
1521
+ patchWidgetProps(selectedIndex, {
1522
+ trueLabel: e.target.value || undefined,
1523
+ })
1524
+ }
1525
+ className={inputClass}
1526
+ />
1527
+ </label>
1528
+ <label className={fieldClass}>
1529
+ {fieldLabel("panel.config.tableBooleanFalseLabel")}
1530
+ <Input
1531
+ value={selectedColumn.widgetProps?.falseLabel ?? ""}
1532
+ onChange={(e) =>
1533
+ patchWidgetProps(selectedIndex, {
1534
+ falseLabel: e.target.value || undefined,
1535
+ })
1536
+ }
1537
+ className={inputClass}
1538
+ />
1539
+ </label>
1540
+ </div>
1541
+ <label className={fieldClass}>
1542
+ {fieldLabel("panel.config.tableActionOnTrue", "panel.config.tableActionBlueprintHint")}
1543
+ <BlueprintNodeSelect
1544
+ value={selectedColumn.widgetProps?.actions?.onTrueBlueprintNodeId}
1545
+ options={blueprintNodeOptions}
1546
+ className={inputClass}
1547
+ noneLabel={t("panel.config.tableActionBlueprintNone")}
1548
+ onChange={(next) =>
1549
+ patchWidgetProps(selectedIndex, {
1550
+ actions: {
1551
+ ...(selectedColumn.widgetProps?.actions ?? {}),
1552
+ onTrueBlueprintNodeId: next,
1553
+ },
1554
+ })
1555
+ }
1556
+ />
1557
+ </label>
1558
+ <label className={fieldClass}>
1559
+ {fieldLabel("panel.config.tableActionOnFalse", "panel.config.tableActionBlueprintHint")}
1560
+ <BlueprintNodeSelect
1561
+ value={selectedColumn.widgetProps?.actions?.onFalseBlueprintNodeId}
1562
+ options={blueprintNodeOptions}
1563
+ className={inputClass}
1564
+ noneLabel={t("panel.config.tableActionBlueprintNone")}
1565
+ onChange={(next) =>
1566
+ patchWidgetProps(selectedIndex, {
1567
+ actions: {
1568
+ ...(selectedColumn.widgetProps?.actions ?? {}),
1569
+ onFalseBlueprintNodeId: next,
1570
+ },
1571
+ })
1572
+ }
1573
+ />
1574
+ </label>
1575
+ </>
1576
+ ) : null}
1577
+
1578
+ </div>
1579
+
1580
+ <div className="space-y-2">
1581
+ <div className="flex items-center justify-between gap-2">
1582
+ <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
1583
+ <span>{t("panel.config.tableValueMap")}</span>
1584
+ <ConfigHintIcon label={t("panel.config.tableValueMap")}>
1585
+ <div>
1586
+ {selectedColumn.widget === "boolean"
1587
+ ? t("panel.config.tableValueMapBooleanHint")
1588
+ : t("panel.config.tableValueMapHint")}
1589
+ </div>
1590
+ </ConfigHintIcon>
1591
+ </div>
1592
+ <Button
1593
+ type="button"
1594
+ variant="outline"
1595
+ size="sm"
1596
+ className="h-7 px-2 text-[11px]"
1597
+ onClick={() => {
1598
+ const rule: TableValueMapRule = {
1599
+ when: createDefaultCondition(),
1600
+ value: selectedColumn.widget === "boolean" ? true : "",
1601
+ };
1602
+ updateColumnAt(selectedIndex, {
1603
+ valueMap: [...(selectedColumn.valueMap ?? []), rule],
1604
+ });
1605
+ }}
1606
+ >
1607
+ {t("panel.config.tableAddValueMap")}
1608
+ </Button>
1609
+ </div>
1610
+ {selectedColumn.widget === "boolean" && booleanValueMapConflicts.length > 0 ? (
1611
+ <div className="rounded-md border border-amber-300/70 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-200">
1612
+ {booleanValueMapConflicts.map((conflict) => (
1613
+ <div key={conflict.key}>
1614
+ {t("panel.config.tableValueMapBooleanConflict", {
1615
+ indexes: conflict.ruleIndexes.join(", "),
1616
+ })}
1617
+ </div>
1618
+ ))}
1619
+ </div>
1620
+ ) : null}
1621
+ {(selectedColumn.valueMap ?? []).map((rule, ruleIndex) => (
1622
+ <div
1623
+ key={`vm-${ruleIndex}`}
1624
+ className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-2"
1625
+ >
1626
+ <div className="flex items-center justify-between gap-2">
1627
+ <div className="text-[11px] font-medium text-muted-foreground">
1628
+ {t("panel.config.tableMapItem", { n: ruleIndex + 1 })}
1629
+ </div>
1630
+ <Button
1631
+ type="button"
1632
+ variant="ghost"
1633
+ size="sm"
1634
+ className="h-6 px-1.5 text-[11px] text-destructive"
1635
+ onClick={() => {
1636
+ const next = (selectedColumn.valueMap ?? []).filter((_, i) => i !== ruleIndex);
1637
+ updateColumnAt(selectedIndex, { valueMap: next.length ? next : undefined });
1638
+ }}
1639
+ >
1640
+ ×
1641
+ </Button>
1642
+ </div>
1643
+ <ConditionEditor
1644
+ value={rule.when}
1645
+ showField={false}
1646
+ fieldClass={fieldClass}
1647
+ inputClass={inputClass}
1648
+ onChange={(when) => {
1649
+ const next = [...(selectedColumn.valueMap ?? [])];
1650
+ next[ruleIndex] = { ...rule, when };
1651
+ updateColumnAt(selectedIndex, { valueMap: next });
1652
+ }}
1653
+ />
1654
+ <label className={fieldClass}>
1655
+ {selectedColumn.widget === "boolean"
1656
+ ? fieldLabel("panel.config.tableDisplayBoolean", "panel.config.tableDisplayBooleanHint")
1657
+ : fieldLabel("panel.config.tableDisplayValue", "panel.config.tableDisplayValueHint")}
1658
+ {selectedColumn.widget === "boolean" ? (
1659
+ <Select
1660
+ value={booleanMapSelectValue(rule.value)}
1661
+ onValueChange={(value) => {
1662
+ const next = [...(selectedColumn.valueMap ?? [])];
1663
+ next[ruleIndex] = {
1664
+ ...rule,
1665
+ value: value === "true",
1666
+ };
1667
+ updateColumnAt(selectedIndex, { valueMap: next });
1668
+ }}
1669
+ >
1670
+ <SelectTrigger className={inputClass}>
1671
+ <SelectValue />
1672
+ </SelectTrigger>
1673
+ <SelectContent>
1674
+ <SelectItem value="true">{t("panel.config.tableBooleanYes")}</SelectItem>
1675
+ <SelectItem value="false">{t("panel.config.tableBooleanNo")}</SelectItem>
1676
+ </SelectContent>
1677
+ </Select>
1678
+ ) : (
1679
+ <Input
1680
+ value={leafValueText(rule.value)}
1681
+ onChange={(e) => {
1682
+ const next = [...(selectedColumn.valueMap ?? [])];
1683
+ next[ruleIndex] = {
1684
+ ...rule,
1685
+ value: e.target.value,
1686
+ };
1687
+ updateColumnAt(selectedIndex, { valueMap: next });
1688
+ }}
1689
+ placeholder="{current}"
1690
+ className={inputClass}
1691
+ />
1692
+ )}
1693
+ </label>
1694
+ </div>
1695
+ ))}
1696
+ <label className={fieldClass}>
1697
+ {fieldLabel("panel.config.tableDisplayTemplate", "panel.config.tableDisplayTemplateHint")}
1698
+ <Input
1699
+ value={selectedColumn.displayTemplate ?? ""}
1700
+ onChange={(e) =>
1701
+ updateColumnAt(selectedIndex, {
1702
+ displayTemplate: e.target.value.trim() ? e.target.value : undefined,
1703
+ })
1704
+ }
1705
+ placeholder="{current}分"
1706
+ className={inputClass}
1707
+ />
1708
+ </label>
1709
+ </div>
1710
+
1711
+ <div className="space-y-2 border-t border-border/50 pt-2">
1712
+ <div className="flex items-center justify-between gap-2">
1713
+ <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
1714
+ <span>{t("panel.config.tableCellStyleRules")}</span>
1715
+ <ConfigHintIcon label={t("panel.config.tableCellStyleRules")}>
1716
+ <div>{t("panel.config.tableCellStyleRulesHint")}</div>
1717
+ </ConfigHintIcon>
1718
+ </div>
1719
+ <Button
1720
+ type="button"
1721
+ variant="outline"
1722
+ size="sm"
1723
+ className="h-7 px-2 text-[11px]"
1724
+ onClick={() => {
1725
+ const rule: TableStyleRule = {
1726
+ when: createDefaultCondition(),
1727
+ style: { backgroundColor: "#fef3c7" },
1728
+ };
1729
+ updateColumnAt(selectedIndex, {
1730
+ cellStyleRules: [...(selectedColumn.cellStyleRules ?? []), rule],
1731
+ });
1732
+ }}
1733
+ >
1734
+ {t("panel.config.tableAddCellStyleRule")}
1735
+ </Button>
1736
+ </div>
1737
+ {(selectedColumn.cellStyleRules?.length
1738
+ ? selectedColumn.cellStyleRules
1739
+ : [
1740
+ {
1741
+ when: createDefaultCondition(),
1742
+ style: { backgroundColor: "" },
1743
+ },
1744
+ ]
1745
+ ).map((rule, ruleIndex) => {
1746
+ const isPlaceholder = !(selectedColumn.cellStyleRules?.length);
1747
+ return (
1748
+ <div key={`csr-${ruleIndex}`} className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-2">
1749
+ <div className="flex items-center justify-between gap-2">
1750
+ <div className="text-[11px] font-medium text-muted-foreground">
1751
+ {t("panel.config.tableStyleRuleItem", { n: ruleIndex + 1 })}
1752
+ </div>
1753
+ {!isPlaceholder ? (
1754
+ <Button
1755
+ type="button"
1756
+ variant="ghost"
1757
+ size="sm"
1758
+ className="h-6 px-1.5 text-[11px] text-destructive"
1759
+ onClick={() => {
1760
+ const next = (selectedColumn.cellStyleRules ?? []).filter(
1761
+ (_, i) => i !== ruleIndex
1762
+ );
1763
+ updateColumnAt(selectedIndex, {
1764
+ cellStyleRules: next.length ? next : undefined,
1765
+ });
1766
+ }}
1767
+ >
1768
+ ×
1769
+ </Button>
1770
+ ) : null}
1771
+ </div>
1772
+ <ConditionEditor
1773
+ value={rule.when}
1774
+ showField={true}
1775
+ fieldClass={fieldClass}
1776
+ inputClass={inputClass}
1777
+ onChange={(when) => {
1778
+ const base = selectedColumn.cellStyleRules ?? [
1779
+ { when: createDefaultCondition(), style: {} },
1780
+ ];
1781
+ const next = [...base];
1782
+ next[ruleIndex] = { ...rule, when };
1783
+ updateColumnAt(selectedIndex, { cellStyleRules: next });
1784
+ }}
1785
+ />
1786
+ {colorField(
1787
+ "panel.config.tableBgColor",
1788
+ rule.style?.backgroundColor ?? "",
1789
+ (nextColor) => {
1790
+ const base = selectedColumn.cellStyleRules ?? [
1791
+ { when: createDefaultCondition(), style: {} },
1792
+ ];
1793
+ const next = [...base];
1794
+ next[ruleIndex] = {
1795
+ ...rule,
1796
+ style: {
1797
+ ...(rule.style ?? {}),
1798
+ backgroundColor: nextColor || undefined,
1799
+ },
1800
+ };
1801
+ updateColumnAt(selectedIndex, { cellStyleRules: next });
1802
+ },
1803
+ "panel.config.tableBgColorHint",
1804
+ "#fef3c7"
1805
+ )}
1806
+ </div>
1807
+ );
1808
+ })}
1809
+ </div>
1810
+ </div>
1811
+ ) : null}
1812
+
1813
+ <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
1814
+ {groupTitle("panel.config.groupTableAppearance", "panel.config.groupTableAppearanceHint")}
1815
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
1816
+ <label className="flex items-center gap-2 text-[11px]">
1817
+ <Switch
1818
+ checked={table.showHeader ?? true}
1819
+ onCheckedChange={(checked) => patchTable({ showHeader: checked })}
1820
+ />
1821
+ <span>{t("panel.config.tableShowHeader")}</span>
1822
+ </label>
1823
+ <label className="flex items-center gap-2 text-[11px]">
1824
+ <Switch
1825
+ checked={table.stripe ?? false}
1826
+ onCheckedChange={(checked) => patchTable({ stripe: checked })}
1827
+ />
1828
+ <span className="inline-flex items-center gap-1">
1829
+ {t("panel.config.tableStripe")}
1830
+ <ConfigHintIcon label={t("panel.config.tableStripe")}>
1831
+ <div>{t("panel.config.tableStripeHint")}</div>
1832
+ </ConfigHintIcon>
1833
+ </span>
1834
+ </label>
1835
+ </div>
1836
+ <label className={fieldClass}>
1837
+ {fieldLabel("panel.config.tableRowHeight", "panel.config.tableRowHeightHint")}
1838
+ <Input
1839
+ type="number"
1840
+ min={20}
1841
+ max={120}
1842
+ value={table.rowHeight ?? 36}
1843
+ onChange={(e) =>
1844
+ patchTable({
1845
+ rowHeight: Math.max(20, Math.min(120, Number(e.target.value) || 36)),
1846
+ })
1847
+ }
1848
+ className={inputClass}
1849
+ />
1850
+ </label>
1851
+ <div className="grid grid-cols-2 gap-2">
1852
+ {colorField(
1853
+ "panel.config.tableStyleColor",
1854
+ table.tableStyle?.color ?? "",
1855
+ (next) => patchTableStyle({ color: next || undefined }),
1856
+ undefined,
1857
+ "#111827"
1858
+ )}
1859
+ <label className={fieldClass}>
1860
+ {fieldLabel("panel.config.tableStyleFontSize")}
1861
+ <Input
1862
+ type="number"
1863
+ min={8}
1864
+ max={48}
1865
+ value={table.tableStyle?.fontSize ?? 12}
1866
+ onChange={(e) =>
1867
+ patchTableStyle({
1868
+ fontSize: Math.max(8, Math.min(48, Number(e.target.value) || 12)),
1869
+ })
1870
+ }
1871
+ className={inputClass}
1872
+ />
1873
+ </label>
1874
+ {colorField(
1875
+ "panel.config.tableHeaderBg",
1876
+ table.headerStyle?.backgroundColor ?? "",
1877
+ (next) => patchHeaderStyle({ backgroundColor: next || undefined }),
1878
+ undefined,
1879
+ "#f3f4f6"
1880
+ )}
1881
+ <label className={fieldClass}>
1882
+ {fieldLabel("panel.config.tableHeaderFontSize")}
1883
+ <Input
1884
+ type="number"
1885
+ min={8}
1886
+ max={48}
1887
+ value={table.headerStyle?.fontSize ?? 12}
1888
+ onChange={(e) =>
1889
+ patchHeaderStyle({
1890
+ fontSize: Math.max(8, Math.min(48, Number(e.target.value) || 12)),
1891
+ })
1892
+ }
1893
+ className={inputClass}
1894
+ />
1895
+ </label>
1896
+ </div>
1897
+ </div>
1898
+
1899
+ <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
1900
+ <div className="flex items-center justify-between gap-2">
1901
+ <div className="flex items-center gap-1">
1902
+ <div className="text-[11px] font-semibold text-muted-foreground">
1903
+ {t("panel.config.tableRowStyleRules")}
1904
+ </div>
1905
+ <ConfigHintIcon label={t("panel.config.tableRowStyleRules")}>
1906
+ <div>{t("panel.config.tableRowStyleRulesHint")}</div>
1907
+ </ConfigHintIcon>
1908
+ </div>
1909
+ <Button
1910
+ type="button"
1911
+ variant="outline"
1912
+ size="sm"
1913
+ className="h-7 px-2 text-[11px]"
1914
+ onClick={() => {
1915
+ const rule: TableStyleRule = {
1916
+ when: createDefaultCondition(),
1917
+ style: { backgroundColor: "#ecfccb" },
1918
+ };
1919
+ patchTable({
1920
+ rowStyleRules: [...(table.rowStyleRules ?? []), rule],
1921
+ });
1922
+ }}
1923
+ >
1924
+ {t("panel.config.tableAddRowStyleRule")}
1925
+ </Button>
1926
+ </div>
1927
+ {(table.rowStyleRules ?? []).map((rule, ruleIndex) => (
1928
+ <div key={`rsr-${ruleIndex}`} className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-2">
1929
+ <div className="flex items-center justify-between gap-2">
1930
+ <div className="text-[11px] font-medium text-muted-foreground">
1931
+ {t("panel.config.tableStyleRuleItem", { n: ruleIndex + 1 })}
1932
+ </div>
1933
+ <Button
1934
+ type="button"
1935
+ variant="ghost"
1936
+ size="sm"
1937
+ className="h-6 px-1.5 text-[11px] text-destructive"
1938
+ onClick={() => {
1939
+ const next = (table.rowStyleRules ?? []).filter((_, i) => i !== ruleIndex);
1940
+ patchTable({ rowStyleRules: next.length ? next : undefined });
1941
+ }}
1942
+ >
1943
+ ×
1944
+ </Button>
1945
+ </div>
1946
+ <ConditionEditor
1947
+ value={rule.when}
1948
+ showField={true}
1949
+ fieldClass={fieldClass}
1950
+ inputClass={inputClass}
1951
+ onChange={(when) => {
1952
+ const next = [...(table.rowStyleRules ?? [])];
1953
+ next[ruleIndex] = { ...rule, when };
1954
+ patchTable({ rowStyleRules: next });
1955
+ }}
1956
+ />
1957
+ {colorField(
1958
+ "panel.config.tableBgColor",
1959
+ rule.style?.backgroundColor ?? "",
1960
+ (nextColor) => {
1961
+ const next = [...(table.rowStyleRules ?? [])];
1962
+ next[ruleIndex] = {
1963
+ ...rule,
1964
+ style: {
1965
+ ...(rule.style ?? {}),
1966
+ backgroundColor: nextColor || undefined,
1967
+ },
1968
+ };
1969
+ patchTable({ rowStyleRules: next });
1970
+ },
1971
+ "panel.config.tableBgColorHint",
1972
+ "#ecfccb"
1973
+ )}
1974
+ </div>
1975
+ ))}
1976
+ {(table.rowStyleRules ?? []).length === 0 ? (
1977
+ <div className="text-[11px] text-muted-foreground">
1978
+ {t("panel.config.tableNoRowStyleRules")}
1979
+ </div>
1980
+ ) : null}
1981
+ </div>
1982
+ </fieldset>
1983
+
1984
+ <Dialog open={rowsEditorOpen} onOpenChange={setRowsEditorOpen}>
1985
+ <DialogContent
1986
+ movable={false}
1987
+ resizable={false}
1988
+ className="z-[10150] !flex h-[min(78vh,560px)] max-h-[min(78vh,560px)] w-[min(94vw,680px)] max-w-[680px] !flex-col gap-0 overflow-hidden border-border/60 bg-background p-0 shadow-2xl sm:rounded-xl"
1989
+ style={{
1990
+ display: "flex",
1991
+ flexDirection: "column",
1992
+ height: "min(78vh, 560px)",
1993
+ maxHeight: "min(78vh, 560px)",
1994
+ overflow: "hidden",
1995
+ padding: 0,
1996
+ gap: 0,
1997
+ }}
1998
+ >
1999
+ <div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
2000
+ <DialogHeader className="shrink-0 space-y-0.5 border-b border-border/50 px-4 py-2.5 pr-12 text-left">
2001
+ <DialogTitle className="text-[14px] font-semibold tracking-tight">
2002
+ {t("panel.config.tableRowsEditTitle")}
2003
+ </DialogTitle>
2004
+ <DialogDescription className="text-[12px] leading-4 text-muted-foreground">
2005
+ {t("panel.config.tableRowsEditDesc")}
2006
+ </DialogDescription>
2007
+ </DialogHeader>
2008
+
2009
+ <div className="relative min-h-0 flex-1 overflow-hidden">
2010
+ <div className="absolute inset-0 p-2">
2011
+ <JsonCodeEditor
2012
+ className="h-full w-full"
2013
+ value={rowsEditorDraft}
2014
+ onChange={(v) => {
2015
+ setRowsEditorDraft(v);
2016
+ setRowsEditorError(null);
2017
+ }}
2018
+ />
2019
+ </div>
2020
+ </div>
2021
+
2022
+ {rowsEditorError ? (
2023
+ <div className="shrink-0 px-4 py-1.5 text-[12px] text-destructive">
2024
+ {rowsEditorError}
2025
+ </div>
2026
+ ) : null}
2027
+
2028
+ <DialogFooter className="shrink-0 flex-row justify-end gap-2 border-t border-border/50 bg-muted/20 px-3 py-2 sm:space-x-0">
2029
+ <Button
2030
+ type="button"
2031
+ variant="ghost"
2032
+ className="h-8 rounded-md px-3"
2033
+ onClick={() => setRowsEditorOpen(false)}
2034
+ >
2035
+ {t("common.cancel")}
2036
+ </Button>
2037
+ <Button
2038
+ type="button"
2039
+ className="h-8 rounded-md px-4"
2040
+ onClick={() => {
2041
+ const trimmed = rowsEditorDraft.trim();
2042
+ if (!trimmed) {
2043
+ patchTable({ rowsText: "" });
2044
+ setRowsEditorOpen(false);
2045
+ return;
2046
+ }
2047
+ try {
2048
+ const parsed = JSON.parse(trimmed);
2049
+ if (!Array.isArray(parsed)) {
2050
+ setRowsEditorError(t("panel.config.tableRowsEditNeedArray"));
2051
+ return;
2052
+ }
2053
+ patchTable({ rowsText: JSON.stringify(parsed, null, 2) });
2054
+ setRowsEditorOpen(false);
2055
+ } catch {
2056
+ setRowsEditorError(t("panel.config.tableRowsEditInvalidJson"));
2057
+ }
2058
+ }}
2059
+ >
2060
+ {t("common.save")}
2061
+ </Button>
2062
+ </DialogFooter>
2063
+ </div>
2064
+ </DialogContent>
2065
+ </Dialog>
2066
+
2067
+ <Dialog open={mockOpen} onOpenChange={setMockOpen}>
2068
+ <DialogContent className="z-[10150] max-w-lg">
2069
+ <DialogHeader>
2070
+ <DialogTitle>{t("panel.config.tableRowsMockTitle")}</DialogTitle>
2071
+ <DialogDescription>{t("panel.config.tableRowsMockDesc")}</DialogDescription>
2072
+ </DialogHeader>
2073
+ <div className="grid grid-cols-2 gap-2">
2074
+ <label className={fieldClass}>
2075
+ {fieldLabel("panel.config.tableMockRowCount")}
2076
+ <Input
2077
+ type="number"
2078
+ min={1}
2079
+ max={200}
2080
+ value={mockRowCount}
2081
+ onChange={(e) =>
2082
+ setMockRowCount(Math.max(1, Math.min(200, Number(e.target.value) || 5)))
2083
+ }
2084
+ className={inputClass}
2085
+ />
2086
+ </label>
2087
+ <label className={fieldClass}>
2088
+ {fieldLabel("panel.config.tableMockLocale")}
2089
+ <Select
2090
+ value={mockLocale}
2091
+ onValueChange={(v) => setMockLocale(v as TableMockLocale)}
2092
+ >
2093
+ <SelectTrigger className={inputClass}>
2094
+ <SelectValue />
2095
+ </SelectTrigger>
2096
+ <SelectContent>
2097
+ <SelectItem value="zh">{t("panel.config.tableMockLocaleZh")}</SelectItem>
2098
+ <SelectItem value="en">{t("panel.config.tableMockLocaleEn")}</SelectItem>
2099
+ </SelectContent>
2100
+ </Select>
2101
+ </label>
2102
+ <label className={fieldClass}>
2103
+ {fieldLabel("panel.config.tableMockNamePrefix")}
2104
+ <Input
2105
+ value={mockNamePrefix}
2106
+ onChange={(e) => setMockNamePrefix(e.target.value)}
2107
+ placeholder="Demo"
2108
+ className={inputClass}
2109
+ />
2110
+ </label>
2111
+ <label className={fieldClass}>
2112
+ {fieldLabel("panel.config.tableMockSeed")}
2113
+ <Input
2114
+ value={mockSeed}
2115
+ onChange={(e) => setMockSeed(e.target.value)}
2116
+ placeholder="optional"
2117
+ className={inputClass}
2118
+ />
2119
+ </label>
2120
+ <label className={`${fieldClass} col-span-2`}>
2121
+ {fieldLabel("panel.config.tableMockStatusValues")}
2122
+ <Input
2123
+ value={mockStatusValues}
2124
+ onChange={(e) => setMockStatusValues(e.target.value)}
2125
+ placeholder="active,idle,pending"
2126
+ className={inputClass}
2127
+ />
2128
+ </label>
2129
+ <label className={fieldClass}>
2130
+ {fieldLabel("panel.config.tableMockScoreMin")}
2131
+ <Input
2132
+ type="number"
2133
+ value={mockScoreMin}
2134
+ onChange={(e) => setMockScoreMin(Number(e.target.value) || 0)}
2135
+ className={inputClass}
2136
+ />
2137
+ </label>
2138
+ <label className={fieldClass}>
2139
+ {fieldLabel("panel.config.tableMockScoreMax")}
2140
+ <Input
2141
+ type="number"
2142
+ value={mockScoreMax}
2143
+ onChange={(e) => setMockScoreMax(Number(e.target.value) || 100)}
2144
+ className={inputClass}
2145
+ />
2146
+ </label>
2147
+ </div>
2148
+ <div className="flex flex-wrap gap-x-4 gap-y-2 pt-1">
2149
+ <label className="flex items-center gap-2 text-[11px]">
2150
+ <Switch checked={mockIncludeId} onCheckedChange={setMockIncludeId} />
2151
+ <span>{t("panel.config.tableMockIncludeId")}</span>
2152
+ </label>
2153
+ <label className="flex items-center gap-2 text-[11px]">
2154
+ <Switch checked={mockIncludeTimestamp} onCheckedChange={setMockIncludeTimestamp} />
2155
+ <span>{t("panel.config.tableMockIncludeTimestamp")}</span>
2156
+ </label>
2157
+ <label className="flex items-center gap-2 text-[11px]">
2158
+ <Switch checked={mockIncludeExtras} onCheckedChange={setMockIncludeExtras} />
2159
+ <span>{t("panel.config.tableMockIncludeExtras")}</span>
2160
+ </label>
2161
+ </div>
2162
+ <DialogFooter>
2163
+ <Button type="button" variant="outline" onClick={() => setMockOpen(false)}>
2164
+ {t("common.cancel")}
2165
+ </Button>
2166
+ <Button
2167
+ type="button"
2168
+ onClick={() => {
2169
+ const rows = generateMockTableRows(columns, {
2170
+ rowCount: mockRowCount,
2171
+ seed: mockSeed || undefined,
2172
+ namePrefix: mockNamePrefix || undefined,
2173
+ locale: mockLocale,
2174
+ includeId: mockIncludeId,
2175
+ includeTimestamp: mockIncludeTimestamp,
2176
+ includeExtras: mockIncludeExtras,
2177
+ statusValues: mockStatusValues.split(/[,,]/),
2178
+ scoreMin: mockScoreMin,
2179
+ scoreMax: mockScoreMax,
2180
+ });
2181
+ patchTable({ rowsText: stringifyMockTableRows(rows) });
2182
+ setMockOpen(false);
2183
+ }}
2184
+ >
2185
+ {t("panel.config.tableMockGenerate")}
2186
+ </Button>
2187
+ </DialogFooter>
2188
+ </DialogContent>
2189
+ </Dialog>
2190
+ </>
2191
+ );
2192
+ }