@gadmin2n/schematics 0.0.95 → 0.0.96

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.
@@ -2,6 +2,7 @@ import {
2
2
  Body,
3
3
  Controller,
4
4
  Delete,
5
+ ForbiddenException,
5
6
  Get,
6
7
  Param,
7
8
  ParseIntPipe,
@@ -11,6 +12,7 @@ import {
11
12
  UseGuards,
12
13
  } from '@nestjs/common';
13
14
  import { ApiTags } from '@nestjs/swagger';
15
+ import { PrismaService } from 'nestjs-prisma';
14
16
  import AuthGuard, { AllowUnauthorizedRequest } from '../../lib/auth.guard';
15
17
  import {
16
18
  CreateCanvasDto,
@@ -24,13 +26,29 @@ import { CanvasService } from './canvas.service';
24
26
  @UseGuards(AuthGuard)
25
27
  @Controller('canvas')
26
28
  export class CanvasController {
27
- constructor(private readonly canvasService: CanvasService) {}
29
+ constructor(
30
+ private readonly canvasService: CanvasService,
31
+ private readonly prisma: PrismaService,
32
+ ) {}
28
33
 
29
34
  @Get()
30
35
  findAll(@Req() req) {
31
36
  return this.canvasService.findAllByUser(req.user.userid);
32
37
  }
33
38
 
39
+ @Get('all')
40
+ async findAllForAdmin(@Req() req) {
41
+ const record = await this.prisma.user.findFirst({
42
+ where: { userid: req.user.userid },
43
+ select: { roles: true },
44
+ });
45
+ const roles: string[] = record ? JSON.parse(record.roles) : [];
46
+ if (!roles.includes('SYSTEM_ADMIN')) {
47
+ throw new ForbiddenException('仅 SYSTEM_ADMIN 可访问');
48
+ }
49
+ return this.canvasService.findAllCanvases();
50
+ }
51
+
34
52
  @Post()
35
53
  create(@Req() req, @Body() dto: CreateCanvasDto) {
36
54
  return this.canvasService.create(req.user.userid, dto);
@@ -56,12 +74,23 @@ export class CanvasController {
56
74
 
57
75
  @Get(':id')
58
76
  findOne(@Req() req, @Param('id') id: string) {
59
- return this.canvasService.findOne(id, req.user.userid);
77
+ const roles: string[] = req.user?.configPerms?.roles ?? [];
78
+ const isAdmin = roles.includes('SYSTEM_ADMIN');
79
+ return this.canvasService.findOne(
80
+ id,
81
+ isAdmin ? undefined : req.user.userid,
82
+ );
60
83
  }
61
84
 
62
85
  @Put(':id')
63
86
  update(@Req() req, @Param('id') id: string, @Body() dto: UpdateCanvasDto) {
64
- return this.canvasService.update(id, req.user.userid, dto);
87
+ const roles: string[] = req.user?.configPerms?.roles ?? [];
88
+ const isAdmin = roles.includes('SYSTEM_ADMIN');
89
+ return this.canvasService.update(
90
+ id,
91
+ isAdmin ? undefined : req.user.userid,
92
+ dto,
93
+ );
65
94
  }
66
95
 
67
96
  @Delete(':id')
@@ -25,9 +25,9 @@ export class CanvasService {
25
25
  });
26
26
  }
27
27
 
28
- findOne(id: string, userId: string) {
28
+ findOne(id: string, userId?: string) {
29
29
  return this.prisma.canvas.findFirst({
30
- where: { id, userId },
30
+ where: userId ? { id, userId } : { id },
31
31
  });
32
32
  }
33
33
 
@@ -58,10 +58,12 @@ export class CanvasService {
58
58
  });
59
59
  }
60
60
 
61
- async update(id: string, userId: string, dto: UpdateCanvasDto) {
61
+ async update(id: string, userId: string | undefined, dto: UpdateCanvasDto) {
62
62
  if (dto.name !== undefined) {
63
63
  const existing = await this.prisma.canvas.findFirst({
64
- where: { userId, name: dto.name, NOT: { id } },
64
+ where: userId
65
+ ? { userId, name: dto.name, NOT: { id } }
66
+ : { name: dto.name, NOT: { id } },
65
67
  });
66
68
  if (existing) {
67
69
  throw new ConflictException('Canvas 名称已存在');
@@ -71,7 +73,7 @@ export class CanvasService {
71
73
  // 如果改名且已发布,同步更新 page 表的所有名称字段 + code + path
72
74
  if (dto.name !== undefined) {
73
75
  const canvas = await this.prisma.canvas.findFirst({
74
- where: { id, userId },
76
+ where: userId ? { id, userId } : { id },
75
77
  });
76
78
  if (canvas?.publishedPageCode) {
77
79
  const newSlug =
@@ -100,7 +102,7 @@ export class CanvasService {
100
102
  }
101
103
 
102
104
  return this.prisma.canvas.updateMany({
103
- where: { id, userId },
105
+ where: userId ? { id, userId } : { id },
104
106
  data: {
105
107
  ...(dto.name !== undefined && { name: dto.name }),
106
108
  ...(dto.items !== undefined && { items: dto.items }),
@@ -228,6 +230,26 @@ export class CanvasService {
228
230
  return { success: true };
229
231
  }
230
232
 
233
+ async findAllCanvases() {
234
+ const canvases = await this.prisma.canvas.findMany({
235
+ orderBy: { createdAt: 'asc' },
236
+ });
237
+
238
+ const userIds = [...new Set(canvases.map((c) => c.userId))];
239
+ const users = await this.prisma.user.findMany({
240
+ where: { userid: { in: userIds } },
241
+ select: { userid: true, username: true },
242
+ });
243
+ const userMap = new Map(
244
+ users.map((u) => [u.userid, u.username ?? u.userid]),
245
+ );
246
+
247
+ return canvases.map((c) => ({
248
+ ...c,
249
+ ownerName: userMap.get(c.userId) ?? c.userId,
250
+ }));
251
+ }
252
+
231
253
  async savedQueryCreate(dto: CreateSavedQueryDto) {
232
254
  const sql = dto.sql.trim();
233
255
  if (!sql.toUpperCase().startsWith('SELECT')) {
@@ -10,7 +10,7 @@
10
10
  "@dnd-kit/modifiers": "^9.0.0",
11
11
  "@dnd-kit/sortable": "^7.0.2",
12
12
  "@dnd-kit/utilities": "^3.2.2",
13
- "@gadmin2n/charts": "^0.0.7",
13
+ "@gadmin2n/charts": "^0.0.8",
14
14
  "@gadmin2n/react-common": "^0.0.70",
15
15
  "@monaco-editor/react": "^4.7.0",
16
16
  "@refinedev/antd": "^5.47.0",
@@ -27,12 +27,28 @@ export function generatePrompt(options: PromptOptions): string {
27
27
  parts.push('');
28
28
  }
29
29
 
30
- if (skill) {
30
+ parts.push(`严格按照以下 skill 完成代码修改:`);
31
+ parts.push(
32
+ `1. **canvas-component-edit skill**(位于 .claude/skills/canvas-component-edit/SKILL.md):负责修改流程和推送`,
33
+ );
34
+ parts.push(
35
+ `2. **canvas-design-standard skill**(位于 .claude/skills/canvas-design-standard/SKILL.md):所有手写 UI 代码必须遵循此规范`,
36
+ );
37
+ if (skill && skill !== 'canvas-component-edit') {
31
38
  parts.push(
32
- `严格按照: ${skill} skill(位于 .claude/skills/${skill}/SKILL.md)描述的方法来完成代码的修改。如有不确定的地方,请先询问我,不要自己假设。`,
39
+ `3. **${skill} skill**(位于 .claude/skills/${skill}/SKILL.md):本次任务的主要执行规范`,
33
40
  );
34
- parts.push('');
35
41
  }
42
+ parts.push(``);
43
+ parts.push(`**关于第 2 条的强制要求:**`);
44
+ parts.push(
45
+ `- 最终代码中禁止出现原生 HTML 表单元素(\`<input>\`、\`<button>\`、\`<select>\` 等)`,
46
+ );
47
+ parts.push(
48
+ `- 所有 UI 元素必须使用等价的 Ant Design 组件(如 \`Input\`、\`Button\`、\`Space\` 等)`,
49
+ );
50
+ parts.push(`- 间距使用规范值(8px / 12px / 16px),不得随意硬编码`);
51
+ parts.push('');
36
52
 
37
53
  const canvasItemId = inspectedElement.meta?.canvasItemId ?? '';
38
54
  parts.push('### 更多辅助信息:');
@@ -266,10 +266,26 @@
266
266
  "enableSmooth": "Enable Smooth",
267
267
  "switchTo": "Switch to",
268
268
  "showDownload": "Show Download",
269
- "hideDownload": "Hide Download"
269
+ "hideDownload": "Hide Download",
270
+ "enableSorting": "Enable Sorting",
271
+ "disableSorting": "Disable Sorting",
272
+ "enableFiltering": "Enable Filtering",
273
+ "disableFiltering": "Disable Filtering",
274
+ "configColumns": "Configure Columns"
275
+ },
276
+ "modal": {
277
+ "columnConfigTitle": "Column Settings",
278
+ "columnName": "Column",
279
+ "sortable": "Sort",
280
+ "filterable": "Filter",
281
+ "ok": "OK",
282
+ "cancel": "Cancel",
283
+ "parseFailed": "Failed to parse columns. Please use the table-level toggle from the right-click menu."
270
284
  },
271
285
  "list": {
272
- "title": "My Canvas",
286
+ "title": "Canvas",
287
+ "myCanvases": "My Canvas",
288
+ "othersCanvases": "Others' Canvas",
273
289
  "create": "New Canvas",
274
290
  "published": "Published",
275
291
  "unpublished": "Unpublished",
@@ -268,10 +268,26 @@
268
268
  "enableSmooth": "开启拟合曲线",
269
269
  "switchTo": "切换到",
270
270
  "showDownload": "显示下载按钮",
271
- "hideDownload": "隐藏下载按钮"
271
+ "hideDownload": "隐藏下载按钮",
272
+ "enableSorting": "启用排序",
273
+ "disableSorting": "禁用排序",
274
+ "enableFiltering": "启用过滤",
275
+ "disableFiltering": "禁用过滤",
276
+ "configColumns": "配置列"
277
+ },
278
+ "modal": {
279
+ "columnConfigTitle": "列配置",
280
+ "columnName": "列名",
281
+ "sortable": "排序",
282
+ "filterable": "过滤",
283
+ "ok": "确认",
284
+ "cancel": "取消",
285
+ "parseFailed": "解析列失败,请使用右键菜单的整表开关。"
272
286
  },
273
287
  "list": {
274
- "title": "我的 Canvas",
288
+ "title": "Canvas",
289
+ "myCanvases": "我的 Canvas",
290
+ "othersCanvases": "其他人的 Canvas",
275
291
  "create": "新建 Canvas",
276
292
  "published": "已发布",
277
293
  "unpublished": "未发布",
@@ -16,10 +16,14 @@ import {
16
16
  createCanvas,
17
17
  deleteCanvas,
18
18
  updateCanvas,
19
+ fetchAllCanvases,
20
+ type SavedCanvasWithOwner,
19
21
  } from './canvasApi';
20
22
  import type { SavedCanvas } from './types';
21
23
  import IsolatedLivePreview from './IsolatedLivePreview';
22
24
  import { useTranslation } from 'react-i18next';
25
+ import { useUserPageAccess } from 'hooks/useUserPageAccess';
26
+ import { customRequest } from 'helpers/http';
23
27
 
24
28
  const COLS = 48;
25
29
  const ROW_HEIGHT = 10;
@@ -67,7 +71,33 @@ const CanvasListPage: React.FC = () => {
67
71
  const [loading, setLoading] = useState(true);
68
72
  const { t } = useTranslation();
69
73
 
70
- console.log('[CanvasListPage] rendered, pathname:', window.location.pathname);
74
+ const { roleNames } = useUserPageAccess();
75
+ const isSystemAdmin = roleNames.includes('SYSTEM_ADMIN');
76
+
77
+ const [othersCanvases, setOthersCanvases] = useState<SavedCanvasWithOwner[]>(
78
+ [],
79
+ );
80
+ const [othersLoading, setOthersLoading] = useState(false);
81
+
82
+ const [currentUserId, setCurrentUserId] = useState<string>('');
83
+ useEffect(() => {
84
+ customRequest<{ userid: string }>('userinfo', 'GET')
85
+ .then((res) => {
86
+ setCurrentUserId(res.userid ?? '');
87
+ })
88
+ .catch(() => {});
89
+ }, []);
90
+
91
+ useEffect(() => {
92
+ if (!isSystemAdmin || !currentUserId) return;
93
+ setOthersLoading(true);
94
+ fetchAllCanvases()
95
+ .then((all) => {
96
+ setOthersCanvases(all.filter((c) => c.userId !== currentUserId));
97
+ })
98
+ .catch(() => message.error('加载其他人的 Canvas 失败'))
99
+ .finally(() => setOthersLoading(false));
100
+ }, [isSystemAdmin, currentUserId]);
71
101
 
72
102
  useEffect(() => {
73
103
  fetchCanvases()
@@ -209,6 +239,9 @@ const CanvasListPage: React.FC = () => {
209
239
 
210
240
  {/* Grid */}
211
241
  <Card>
242
+ <Typography.Title level={5} style={{ margin: '0 0 16px 0' }}>
243
+ {t('canvas.list.myCanvases')}
244
+ </Typography.Title>
212
245
  {canvases.length > 0 ? (
213
246
  <div
214
247
  style={{
@@ -359,6 +392,145 @@ const CanvasListPage: React.FC = () => {
359
392
  )}
360
393
  </Card>
361
394
 
395
+ {/* 其他人的 Canvas(仅 system_admin 可见) */}
396
+ {isSystemAdmin && (
397
+ <Card style={{ marginTop: 16 }}>
398
+ <Typography.Title level={5} style={{ margin: '0 0 16px 0' }}>
399
+ {t('canvas.list.othersCanvases')}
400
+ </Typography.Title>
401
+ {othersLoading ? (
402
+ <div
403
+ style={{
404
+ display: 'flex',
405
+ justifyContent: 'center',
406
+ padding: '40px 0',
407
+ }}
408
+ >
409
+ <Spin size="large" />
410
+ </div>
411
+ ) : othersCanvases.length > 0 ? (
412
+ <div
413
+ style={{
414
+ display: 'grid',
415
+ gridTemplateColumns: 'repeat(3, 1fr)',
416
+ gap: 20,
417
+ }}
418
+ >
419
+ {othersCanvases.map((canvas) => (
420
+ <div
421
+ key={canvas.id}
422
+ style={{
423
+ background: '#fff',
424
+ borderRadius: 8,
425
+ overflow: 'hidden',
426
+ border: '1px solid #e5e5e5',
427
+ cursor: 'pointer',
428
+ transition: 'box-shadow 0.2s',
429
+ }}
430
+ onMouseEnter={(e) => {
431
+ (e.currentTarget as HTMLDivElement).style.boxShadow =
432
+ '0 4px 12px rgba(0,0,0,0.1)';
433
+ }}
434
+ onMouseLeave={(e) => {
435
+ (e.currentTarget as HTMLDivElement).style.boxShadow =
436
+ 'none';
437
+ }}
438
+ onClick={() => navigate(`/admin/canvas/edit/${canvas.id}`)}
439
+ >
440
+ {/* Thumbnail */}
441
+ <div
442
+ style={{
443
+ height: 180,
444
+ overflow: 'hidden',
445
+ background: '#f0f0f0',
446
+ position: 'relative',
447
+ pointerEvents: 'none',
448
+ }}
449
+ >
450
+ {canvas.items.length > 0 ? (
451
+ <div
452
+ style={{
453
+ transform: 'scale(0.28)',
454
+ transformOrigin: 'top left',
455
+ width: 1200,
456
+ height: 640,
457
+ }}
458
+ >
459
+ <IsolatedLivePreview code={buildPreviewCode(canvas)} />
460
+ </div>
461
+ ) : (
462
+ <div
463
+ style={{
464
+ display: 'flex',
465
+ alignItems: 'center',
466
+ justifyContent: 'center',
467
+ height: '100%',
468
+ color: '#bbb',
469
+ fontSize: 14,
470
+ }}
471
+ >
472
+ 空画布
473
+ </div>
474
+ )}
475
+ </div>
476
+ {/* Info */}
477
+ <div
478
+ style={{
479
+ padding: '12px 16px',
480
+ borderTop: '1px solid #f0f0f0',
481
+ }}
482
+ >
483
+ <div
484
+ style={{
485
+ display: 'flex',
486
+ justifyContent: 'space-between',
487
+ alignItems: 'center',
488
+ marginBottom: 4,
489
+ }}
490
+ >
491
+ <span
492
+ style={{
493
+ fontWeight: 500,
494
+ fontSize: 14,
495
+ overflow: 'hidden',
496
+ textOverflow: 'ellipsis',
497
+ whiteSpace: 'nowrap',
498
+ maxWidth: 180,
499
+ }}
500
+ >
501
+ {canvas.name}
502
+ </span>
503
+ <Tag
504
+ color={canvas.publishedPageCode ? 'green' : 'default'}
505
+ >
506
+ {canvas.publishedPageCode
507
+ ? t('canvas.list.published')
508
+ : t('canvas.list.unpublished')}
509
+ </Tag>
510
+ </div>
511
+ <div
512
+ style={{ fontSize: 12, color: '#999', marginBottom: 4 }}
513
+ >
514
+ 创建者: {canvas.ownerName}
515
+ </div>
516
+ <div style={{ fontSize: 12, color: '#999' }}>
517
+ 修改于{' '}
518
+ {new Date(canvas.updatedAt).toLocaleString('zh-CN')}
519
+ </div>
520
+ </div>
521
+ </div>
522
+ ))}
523
+ </div>
524
+ ) : (
525
+ <div
526
+ style={{ textAlign: 'center', color: '#999', padding: '40px 0' }}
527
+ >
528
+ 没有其他用户的 Canvas
529
+ </div>
530
+ )}
531
+ </Card>
532
+ )}
533
+
362
534
  {/* 重命名 Modal */}
363
535
  <Modal
364
536
  title={t('canvas.list.renameTitle')}
@@ -35,6 +35,7 @@ import BarChartDataSourceModal from './components/BarChartDataSourceModal';
35
35
  import LineChartDataSourceModal from './components/LineChartDataSourceModal';
36
36
  import RadarChartDataSourceModal from './components/RadarChartDataSourceModal';
37
37
  import MultiChartDataSourceModal from './components/MultiChartDataSourceModal';
38
+ import TableConfigModal from './components/TableConfigModal';
38
39
  import PromptModal from './components/PromptModal';
39
40
  import { createSectionCompactor } from './sectionCompactor';
40
41
  import type { CanvasItem as CanvasItemType } from './types';
@@ -166,9 +167,19 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
166
167
  onConfirm: (value: string) => void;
167
168
  } | null>(null);
168
169
 
170
+ const [virtualTableConfigModal, setVirtualTableConfigModal] = useState<{
171
+ virtualCode: string;
172
+ onConfirm: (newVirtualCode: string) => void;
173
+ } | null>(null);
174
+
169
175
  const menuActionContext: MenuActionContext = useMemo(
170
176
  () => ({
171
177
  showPrompt: (opts) => setPromptModal(opts),
178
+ openTableColumnConfig: (opts) => {
179
+ // 与独立 Table 走 registry 的 configModal 互斥,避免两个 modal 同时挂载
180
+ setConfigModal(null);
181
+ setVirtualTableConfigModal(opts);
182
+ },
172
183
  }),
173
184
  [],
174
185
  );
@@ -492,7 +503,9 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
492
503
  label:
493
504
  componentType === 'MultiChart'
494
505
  ? (t('canvas.configCharts') ?? '配置显示图表与顺序')
495
- : t('canvas.config'),
506
+ : componentType === 'Table'
507
+ ? (t('canvas.menu.configColumns') ?? '配置列')
508
+ : t('canvas.config'),
496
509
  onClick: () => {
497
510
  setConfigModal({ id: itemMenu.id, componentType });
498
511
  setItemMenu(null);
@@ -566,7 +579,7 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
566
579
  onConfirm: (value) => {
567
580
  if (!value.trim()) return;
568
581
  const prompt = generatePrompt({
569
- skill: null,
582
+ skill: 'canvas-component-edit',
570
583
  pageInfo: agent?.pageInfo ?? {
571
584
  resourceName: '',
572
585
  pageType: 'unknown',
@@ -1471,6 +1484,18 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
1471
1484
  onCancel={() => setDataSourceModal(null)}
1472
1485
  />
1473
1486
 
1487
+ {/* MultiChart 切到 table 时的列配置 Modal(virtualCode 桥接) */}
1488
+ {virtualTableConfigModal && (
1489
+ <TableConfigModal
1490
+ code={virtualTableConfigModal.virtualCode}
1491
+ onConfirm={(newVirtualCode) => {
1492
+ virtualTableConfigModal.onConfirm(newVirtualCode);
1493
+ setVirtualTableConfigModal(null);
1494
+ }}
1495
+ onCancel={() => setVirtualTableConfigModal(null)}
1496
+ />
1497
+ )}
1498
+
1474
1499
  {/* ── 通用文本输入弹窗(如修改标题) ── */}
1475
1500
  <PromptModal
1476
1501
  open={!!promptModal}
@@ -81,3 +81,32 @@ export async function publishCanvas(
81
81
  export async function unpublishCanvas(id: string): Promise<void> {
82
82
  await customRequest(`canvas/${id}/unpublish`, 'DELETE');
83
83
  }
84
+
85
+ // ── Admin: 查看所有人的 canvas ──────────────────────────────────────────────
86
+
87
+ interface CanvasRecordWithOwner extends CanvasRecord {
88
+ ownerName: string;
89
+ }
90
+
91
+ export interface SavedCanvasWithOwner extends SavedCanvas {
92
+ ownerName: string;
93
+ userId: string; // 用于前端过滤掉自己的 canvas
94
+ }
95
+
96
+ function toSavedCanvasWithOwner(
97
+ r: CanvasRecordWithOwner,
98
+ ): SavedCanvasWithOwner {
99
+ return {
100
+ ...toSavedCanvas(r),
101
+ userId: r.userId,
102
+ ownerName: r.ownerName,
103
+ };
104
+ }
105
+
106
+ export async function fetchAllCanvases(): Promise<SavedCanvasWithOwner[]> {
107
+ const records = await customRequest<CanvasRecordWithOwner[]>(
108
+ 'canvas/all',
109
+ 'GET',
110
+ );
111
+ return records.map(toSavedCanvasWithOwner);
112
+ }
@@ -1,5 +1,6 @@
1
1
  import type React from 'react';
2
2
  import MultiChartConfigModal from './components/MultiChartConfigModal';
3
+ import TableConfigModal from './components/TableConfigModal';
3
4
 
4
5
  // ─── 通用配置 Modal 接口 ──────────────────────────────────────────────────────
5
6
  //
@@ -22,4 +23,5 @@ export type ConfigModalComponent = React.FC<CanvasConfigModalProps>;
22
23
 
23
24
  export const CANVAS_CONFIG_REGISTRY: Record<string, ConfigModalComponent> = {
24
25
  MultiChart: MultiChartConfigModal,
26
+ Table: TableConfigModal,
25
27
  };
@@ -6,7 +6,16 @@ import {
6
6
  EditOutlined,
7
7
  SwapOutlined,
8
8
  DownloadOutlined,
9
+ SettingOutlined,
9
10
  } from '@ant-design/icons';
11
+ import {
12
+ readTableFlag,
13
+ writeTableFlag,
14
+ parseMultiChartTableColumns,
15
+ readMultiChartTableFlags,
16
+ writeMultiChartTableFlags,
17
+ buildVirtualTableCode,
18
+ } from './utils/tableCodeUtils';
10
19
 
11
20
  // ─── 组件特有右键菜单操作注册表 ────────────────────────────────────────────────
12
21
  //
@@ -34,6 +43,16 @@ export interface MenuActionContext {
34
43
  defaultValue?: string;
35
44
  onConfirm: (value: string) => void;
36
45
  }) => void;
46
+
47
+ /**
48
+ * MultiChart 切到 table 时打开列配置 modal。
49
+ * - virtualCode:合成的独立 Table JSX,喂给 TableConfigModal
50
+ * - onConfirm:用户确认时回调,参数是 modal 改写后的 newVirtualCode
51
+ */
52
+ openTableColumnConfig?: (opts: {
53
+ virtualCode: string;
54
+ onConfirm: (newVirtualCode: string) => void;
55
+ }) => void;
37
56
  }
38
57
 
39
58
  // ─── NumCard 特有操作 ─────────────────────────────────────────────────────────
@@ -228,15 +247,52 @@ function tableActions(
228
247
  ctx?: MenuActionContext,
229
248
  t?: TFunction,
230
249
  ): MenuProps['items'] {
250
+ const sortableFlag = readTableFlag(code, 'sortable');
251
+ const filterableFlag = readTableFlag(code, 'filterable');
252
+ // Only 'fully on' (sortable={true} or omitted) shows "禁用排序";
253
+ // disabled and partial-array forms show "启用排序" (clicking restores default true).
254
+ const sortingActive = sortableFlag === true;
255
+ const filteringActive = filterableFlag === true;
231
256
  const hasPagination = /pagination\s*=\s*\{\{/.test(code);
232
257
 
258
+ const sortFilterItems: MenuProps['items'] = [
259
+ {
260
+ key: 'toggle-sortable',
261
+ icon: sortingActive ? <MinusOutlined /> : <PlusOutlined />,
262
+ label: sortingActive
263
+ ? (t?.('canvas.menu.disableSorting') ?? '禁用排序')
264
+ : (t?.('canvas.menu.enableSorting') ?? '启用排序'),
265
+ danger: sortingActive ? true : undefined,
266
+ onClick: () => {
267
+ updateCode(
268
+ writeTableFlag(code, 'sortable', sortingActive ? false : true),
269
+ );
270
+ closeMenu();
271
+ },
272
+ },
273
+ {
274
+ key: 'toggle-filterable',
275
+ icon: filteringActive ? <MinusOutlined /> : <PlusOutlined />,
276
+ label: filteringActive
277
+ ? (t?.('canvas.menu.disableFiltering') ?? '禁用过滤')
278
+ : (t?.('canvas.menu.enableFiltering') ?? '启用过滤'),
279
+ danger: filteringActive ? true : undefined,
280
+ onClick: () => {
281
+ updateCode(
282
+ writeTableFlag(code, 'filterable', filteringActive ? false : true),
283
+ );
284
+ closeMenu();
285
+ },
286
+ },
287
+ { type: 'divider' as const, key: 'div-sort-filter' },
288
+ ];
289
+
233
290
  const downloadItems = chartDownloadActions(code, updateCode, closeMenu, t)!;
234
291
 
235
292
  if (hasPagination) {
236
- // 已有分页:显示"修改分页数量"子菜单 + "移除分页"
237
293
  const currentPageSize = code.match(/pageSize\s*:\s*(\d+)/)?.[1] ?? '10';
238
-
239
294
  return [
295
+ ...sortFilterItems,
240
296
  {
241
297
  key: 'change-page-size',
242
298
  icon: <PlusOutlined />,
@@ -271,8 +327,8 @@ function tableActions(
271
327
  ];
272
328
  }
273
329
 
274
- // 无分页:显示"添加分页"子菜单,选择 pageSize
275
330
  return [
331
+ ...sortFilterItems,
276
332
  {
277
333
  key: 'add-pagination',
278
334
  icon: <PlusOutlined />,
@@ -1176,6 +1232,45 @@ function multiChartActions(
1176
1232
  label: t?.('canvas.menu.switchTo') ?? '切换到',
1177
1233
  children: switchItems,
1178
1234
  },
1235
+
1236
+ // ── MultiChart 切到 table 时的「配置列」入口 ──
1237
+ ...(activeChart === 'table' && ctx?.openTableColumnConfig
1238
+ ? [
1239
+ { type: 'divider' as const, key: 'div-multi-table-cols' },
1240
+ {
1241
+ key: 'multi-table-column-config',
1242
+ icon: <SettingOutlined />,
1243
+ label: t?.('canvas.menu.configColumns') ?? '配置列',
1244
+ onClick: () => {
1245
+ const columns = parseMultiChartTableColumns(code);
1246
+ const flags = readMultiChartTableFlags(code);
1247
+ // 解析失败也打开 modal —— modal 内部会显示 Alert + 禁用确认
1248
+ const virtualCode =
1249
+ columns && columns.length > 0
1250
+ ? buildVirtualTableCode(columns, flags)
1251
+ : `<Table dataSource={[]} columns={[]} testId="virtual-multichart-table" />`;
1252
+ closeMenu();
1253
+ ctx?.openTableColumnConfig?.({
1254
+ virtualCode,
1255
+ onConfirm: (newVirtualCode) => {
1256
+ const sortable = readTableFlag(newVirtualCode, 'sortable');
1257
+ const filterable = readTableFlag(
1258
+ newVirtualCode,
1259
+ 'filterable',
1260
+ );
1261
+ const newCode = writeMultiChartTableFlags(
1262
+ code,
1263
+ sortable,
1264
+ filterable,
1265
+ );
1266
+ updateCode(newCode);
1267
+ },
1268
+ });
1269
+ },
1270
+ },
1271
+ ]
1272
+ : []),
1273
+
1179
1274
  ...(chartSpecificItems && chartSpecificItems.length > 0
1180
1275
  ? [
1181
1276
  { type: 'divider' as const, key: 'div-chart-config' },
@@ -0,0 +1,192 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Modal, Table as AntTable, Checkbox, Alert } from 'antd';
3
+ import type { ColumnsType } from 'antd/es/table';
4
+ import { useTranslation } from 'react-i18next';
5
+ import type { CanvasConfigModalProps } from '../canvasConfigRegistry';
6
+ import {
7
+ CANVAS_MODAL_PROPS,
8
+ CANVAS_MODAL_TITLE_STYLE,
9
+ } from './canvasModalProps';
10
+ import {
11
+ parseTableColumnsMeta,
12
+ readTableFlag,
13
+ writeTableFlag,
14
+ } from '../utils/tableCodeUtils';
15
+ import type { ColumnMeta } from '../utils/tableCodeUtils';
16
+
17
+ interface Row extends ColumnMeta {
18
+ key: string; // = dataIndex
19
+ }
20
+
21
+ const TableConfigModal: React.FC<CanvasConfigModalProps> = ({
22
+ code,
23
+ onConfirm,
24
+ onCancel,
25
+ }) => {
26
+ const { t } = useTranslation();
27
+
28
+ // ── 解析 columns ─────────────────────────────────────────────
29
+ const columnsMeta = useMemo(() => parseTableColumnsMeta(code), [code]);
30
+ const parseFailed = columnsMeta == null || columnsMeta.length === 0;
31
+
32
+ const rows: Row[] = useMemo(
33
+ () => (columnsMeta ?? []).map((c) => ({ ...c, key: c.dataIndex })),
34
+ [columnsMeta],
35
+ );
36
+
37
+ // ── 解析当前 sortable / filterable 状态 ────────────────────
38
+ const initialSortSet = useMemo<Set<string>>(() => {
39
+ if (parseFailed) return new Set();
40
+ const flag = readTableFlag(code, 'sortable');
41
+ if (flag === false) return new Set();
42
+ if (flag === true) return new Set(rows.map((r) => r.dataIndex));
43
+ return new Set(flag);
44
+ }, [code, rows, parseFailed]);
45
+
46
+ const initialFilterSet = useMemo<Set<string>>(() => {
47
+ if (parseFailed) return new Set();
48
+ const flag = readTableFlag(code, 'filterable');
49
+ if (flag === false) return new Set();
50
+ if (flag === true) return new Set(rows.map((r) => r.dataIndex));
51
+ return new Set(flag);
52
+ }, [code, rows, parseFailed]);
53
+
54
+ const [sortSet, setSortSet] = useState<Set<string>>(initialSortSet);
55
+ const [filterSet, setFilterSet] = useState<Set<string>>(initialFilterSet);
56
+
57
+ // ── 全选 / 半选状态 ──────────────────────────────────────────
58
+ const totalCount = rows.length;
59
+ const sortAllChecked = totalCount > 0 && sortSet.size === totalCount;
60
+ const sortIndeterminate = sortSet.size > 0 && sortSet.size < totalCount;
61
+ const filterAllChecked = totalCount > 0 && filterSet.size === totalCount;
62
+ const filterIndeterminate = filterSet.size > 0 && filterSet.size < totalCount;
63
+
64
+ // ── 切换单行 ─────────────────────────────────────────────────
65
+ const toggle = (
66
+ set: Set<string>,
67
+ setter: React.Dispatch<React.SetStateAction<Set<string>>>,
68
+ dataIndex: string,
69
+ checked: boolean,
70
+ ) => {
71
+ const next = new Set(set);
72
+ if (checked) next.add(dataIndex);
73
+ else next.delete(dataIndex);
74
+ setter(next);
75
+ };
76
+
77
+ const toggleAll = (
78
+ setter: React.Dispatch<React.SetStateAction<Set<string>>>,
79
+ checked: boolean,
80
+ ) => {
81
+ setter(checked ? new Set(rows.map((r) => r.dataIndex)) : new Set());
82
+ };
83
+
84
+ // ── 写回 code ────────────────────────────────────────────────
85
+ const setToFlag = (set: Set<string>) => {
86
+ if (set.size === totalCount) return true; // 全开
87
+ if (set.size === 0) return false; // 全关
88
+ // 中间状态:保持 rows 顺序输出数组
89
+ return rows.map((r) => r.dataIndex).filter((d) => set.has(d));
90
+ };
91
+
92
+ const handleOk = () => {
93
+ let next = code;
94
+ next = writeTableFlag(next, 'sortable', setToFlag(sortSet));
95
+ next = writeTableFlag(next, 'filterable', setToFlag(filterSet));
96
+ onConfirm(next);
97
+ };
98
+
99
+ // ── 表格列定义 ───────────────────────────────────────────────
100
+ const tableColumns: ColumnsType<Row> = [
101
+ {
102
+ title: t('canvas.modal.columnName', { defaultValue: '列名' }),
103
+ dataIndex: 'title',
104
+ key: 'title',
105
+ },
106
+ {
107
+ title: (
108
+ <Checkbox
109
+ checked={sortAllChecked}
110
+ indeterminate={sortIndeterminate}
111
+ onChange={(e) => toggleAll(setSortSet, e.target.checked)}
112
+ >
113
+ {t('canvas.modal.sortable', { defaultValue: '排序' })}
114
+ </Checkbox>
115
+ ),
116
+ key: 'sortable',
117
+ width: 100,
118
+ align: 'center',
119
+ render: (_v, row) => (
120
+ <Checkbox
121
+ checked={sortSet.has(row.dataIndex)}
122
+ onChange={(e) =>
123
+ toggle(sortSet, setSortSet, row.dataIndex, e.target.checked)
124
+ }
125
+ />
126
+ ),
127
+ },
128
+ {
129
+ title: (
130
+ <Checkbox
131
+ checked={filterAllChecked}
132
+ indeterminate={filterIndeterminate}
133
+ onChange={(e) => toggleAll(setFilterSet, e.target.checked)}
134
+ >
135
+ {t('canvas.modal.filterable', { defaultValue: '过滤' })}
136
+ </Checkbox>
137
+ ),
138
+ key: 'filterable',
139
+ width: 100,
140
+ align: 'center',
141
+ render: (_v, row) => (
142
+ <Checkbox
143
+ checked={filterSet.has(row.dataIndex)}
144
+ onChange={(e) =>
145
+ toggle(filterSet, setFilterSet, row.dataIndex, e.target.checked)
146
+ }
147
+ />
148
+ ),
149
+ },
150
+ ];
151
+
152
+ return (
153
+ <Modal
154
+ {...CANVAS_MODAL_PROPS}
155
+ width={520}
156
+ open
157
+ title={
158
+ <span style={CANVAS_MODAL_TITLE_STYLE}>
159
+ {t('canvas.modal.columnConfigTitle', { defaultValue: '列配置' })}
160
+ </span>
161
+ }
162
+ okText={t('canvas.modal.ok', { defaultValue: '确认' })}
163
+ cancelText={t('canvas.modal.cancel', { defaultValue: '取消' })}
164
+ onOk={handleOk}
165
+ onCancel={onCancel}
166
+ okButtonProps={{ disabled: parseFailed }}
167
+ >
168
+ {parseFailed && (
169
+ <Alert
170
+ type="warning"
171
+ showIcon
172
+ style={{ marginBottom: 12 }}
173
+ message={t('canvas.modal.parseFailed', {
174
+ defaultValue: '解析列失败,请使用右键菜单的整表开关。',
175
+ })}
176
+ />
177
+ )}
178
+ {!parseFailed && (
179
+ <div data-testid="canvas-table-config-modal-table">
180
+ <AntTable<Row>
181
+ dataSource={rows}
182
+ columns={tableColumns}
183
+ pagination={false}
184
+ size="small"
185
+ />
186
+ </div>
187
+ )}
188
+ </Modal>
189
+ );
190
+ };
191
+
192
+ export default TableConfigModal;
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Canvas Table 代码字符串解析 / 改写工具。
3
+ *
4
+ * 这些函数全部以 JSX 字符串为输入输出,不操作 React 组件实例。
5
+ * 由 TableConfigModal 与 canvasContextMenuRegistry 中的 tableActions 共用。
6
+ */
7
+
8
+ export interface ColumnMeta {
9
+ title: string;
10
+ dataIndex: string;
11
+ }
12
+
13
+ export type SortFilterFlag = true | false | string[];
14
+
15
+ /**
16
+ * 在 JSX 对象字面量片段中读取一个字符串属性的值,支持双引号 / 单引号字面量
17
+ * 与 \" \\ \n 等转义序列。无法解析时返回 null。
18
+ */
19
+ function readStringProp(obj: string, propName: string): string | null {
20
+ // 双引号字面量:(?:[^"\\]|\\.)* 表示除 " 和 \ 外的字符 或 反斜杠+任意单字符
21
+ const dq = obj.match(
22
+ new RegExp(`${propName}\\s*:\\s*("(?:[^"\\\\]|\\\\.)*")`),
23
+ );
24
+ if (dq) {
25
+ try {
26
+ return JSON.parse(dq[1]);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ const sq = obj.match(
32
+ new RegExp(`${propName}\\s*:\\s*'((?:[^'\\\\]|\\\\.)*)'`),
33
+ );
34
+ if (sq) {
35
+ // 把单引号字面量转成 JSON 双引号字面量(替换未转义的双引号、保留已转义序列)
36
+ const inner = sq[1].replace(/(^|[^\\])"/g, '$1\\"');
37
+ try {
38
+ return JSON.parse(`"${inner}"`);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * 从 Table 组件的 JSX 字符串中解析 columns 数组里每列的 title/dataIndex。
48
+ *
49
+ * 解析失败(columns 含复杂 render 函数、JSON 不规范等)时返回 null。
50
+ * 调用方据此显示 Alert + 禁用确认。
51
+ */
52
+ export function parseTableColumnsMeta(code: string): ColumnMeta[] | null {
53
+ const match = code.match(/columns\s*=\s*\{(\[[\s\S]*?\])\}/);
54
+ if (!match) return null;
55
+ const arrayLiteral = match[1];
56
+
57
+ // 用 regex 提取每个 { ... } 对象内的 title 和 dataIndex 字符串字面量。
58
+ // 这种方法对含 render: (v) => ... 的列也容错(直接忽略 render)。
59
+ const objects = arrayLiteral.match(/\{[^{}]*\}/g);
60
+ if (!objects || objects.length === 0) return null;
61
+
62
+ const result: ColumnMeta[] = [];
63
+ for (const obj of objects) {
64
+ const title = readStringProp(obj, 'title');
65
+ const dataIndex = readStringProp(obj, 'dataIndex');
66
+ if (title != null && dataIndex != null) {
67
+ result.push({ title, dataIndex });
68
+ }
69
+ }
70
+ return result.length > 0 ? result : null;
71
+ }
72
+
73
+ /**
74
+ * 从 code 字符串中读取 sortable / filterable prop 当前状态。
75
+ *
76
+ * 返回:
77
+ * - true:未设置或显式 {true}(默认全开)
78
+ * - false:显式 {false}
79
+ * - string[]:显式数组形式 {[...]}
80
+ */
81
+ export function readTableFlag(
82
+ code: string,
83
+ propName: 'sortable' | 'filterable',
84
+ ): SortFilterFlag {
85
+ if (new RegExp(`\\b${propName}\\s*=\\s*\\{false\\}`).test(code)) return false;
86
+ const arrayMatch = code.match(
87
+ new RegExp(`\\b${propName}\\s*=\\s*\\{(\\[[^\\]]*\\])\\}`),
88
+ );
89
+ if (arrayMatch) {
90
+ try {
91
+ const parsed = JSON.parse(arrayMatch[1].replace(/'/g, '"'));
92
+ if (Array.isArray(parsed)) return parsed as string[];
93
+ } catch {
94
+ // 解析失败回退为 true
95
+ }
96
+ }
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * 把 sortable / filterable prop 写回 code 字符串。
102
+ *
103
+ * - flag === true:删除 prop(恢复默认)
104
+ * - flag === false:替换/插入 `propName={false}`
105
+ * - flag === string[]:替换/插入 `propName={["a","b"]}`
106
+ *
107
+ * 注入位置:testId= 那行之前;找不到 testId 则回退到 /> 之前。
108
+ */
109
+ export function writeTableFlag(
110
+ code: string,
111
+ propName: 'sortable' | 'filterable',
112
+ flag: SortFilterFlag,
113
+ ): string {
114
+ // 先删除现有 prop(无论什么形式)
115
+ let next = code.replace(
116
+ new RegExp(`\\s*\\b${propName}\\s*=\\s*\\{(true|false|\\[[^\\]]*\\])\\}`),
117
+ '',
118
+ );
119
+
120
+ if (flag === true) return next;
121
+
122
+ const value = flag === false ? '{false}' : `{${JSON.stringify(flag)}}`;
123
+ const inject = `${propName}=${value}`;
124
+
125
+ if (/testId\s*=/.test(next)) {
126
+ next = next.replace(/([ \t]*)(testId\s*=)/, `$1${inject}\n$1$2`);
127
+ } else {
128
+ next = next.replace(/\/>/, ` ${inject}\n/>`);
129
+ }
130
+ return next;
131
+ }
132
+
133
+ // ─── MultiChart-as-table 列配置 helpers ────────────────────────────────
134
+
135
+ /** 在 s 中从 start 位置(指向 open)开始,找出与之匹配的 close 字符的位置;不平衡返回 -1。 */
136
+ function findBalancedClose(
137
+ s: string,
138
+ start: number,
139
+ open: string,
140
+ close: string,
141
+ ): number {
142
+ let depth = 0;
143
+ for (let i = start; i < s.length; i++) {
144
+ if (s[i] === open) depth++;
145
+ else if (s[i] === close) {
146
+ depth--;
147
+ if (depth === 0) return i;
148
+ }
149
+ }
150
+ return -1;
151
+ }
152
+
153
+ /**
154
+ * 定位 chartConfig={...} 的 span。
155
+ * - start:'chartConfig' 文字开始位置
156
+ * - end:JSX 闭合 '}' 之后的位置(半开区间)
157
+ * - jsonStr:JSX 大括号里面的内容(trim 后),通常是一个 JSON 对象字面量
158
+ *
159
+ * 找不到返回 null。
160
+ */
161
+ function findChartConfigSpan(
162
+ code: string,
163
+ ): { start: number; end: number; jsonStr: string } | null {
164
+ const m = code.match(/chartConfig\s*=\s*\{/);
165
+ if (!m || m.index == null) return null;
166
+ const propStart = m.index;
167
+ const jsxOpen = propStart + m[0].length - 1;
168
+ const jsxClose = findBalancedClose(code, jsxOpen, '{', '}');
169
+ if (jsxClose === -1) return null;
170
+ const jsonStr = code.slice(jsxOpen + 1, jsxClose).trim();
171
+ return { start: propStart, end: jsxClose + 1, jsonStr };
172
+ }
173
+
174
+ /**
175
+ * 从 MultiChart code 字符串中解析 data prop,推导出 antd Table columns 元数据。
176
+ * 与 MultiChart 内部 toTableData(data) 保持一致:
177
+ * - 第一列 "分类" / dataIndex "__x"
178
+ * - 后续列对应 data.series 各项的 name
179
+ *
180
+ * 解析失败返回 null(调用方据此显示 Alert + 禁用确认)。
181
+ *
182
+ * 内部用平衡大括号匹配定位 data={{...}},避免 lazy 正则因 series 中嵌套 [...] 而截断。
183
+ */
184
+ export function parseMultiChartTableColumns(code: string): ColumnMeta[] | null {
185
+ const m = code.match(/data\s*=\s*\{/);
186
+ if (!m || m.index == null) return null;
187
+ const jsxOpen = m.index + m[0].length - 1;
188
+ const jsxClose = findBalancedClose(code, jsxOpen, '{', '}');
189
+ if (jsxClose === -1) return null;
190
+ const expr = code.slice(jsxOpen + 1, jsxClose);
191
+
192
+ // 仅在 series: [...] 范围内抽 name 字段,避免 tooltip.name / itemStyle.name 等污染
193
+ const seriesIdx = expr.search(/series\s*:\s*\[/);
194
+ if (seriesIdx === -1) return null;
195
+ const arrOpen = expr.indexOf('[', seriesIdx);
196
+ if (arrOpen === -1) return null;
197
+ const arrClose = findBalancedClose(expr, arrOpen, '[', ']');
198
+ if (arrClose === -1) return null;
199
+ const seriesBody = expr.slice(arrOpen + 1, arrClose);
200
+
201
+ const nameMatches = Array.from(
202
+ seriesBody.matchAll(/name\s*:\s*['"]([^'"]+)['"]/g),
203
+ );
204
+ const seriesNames = nameMatches.map((mm) => mm[1]);
205
+ if (seriesNames.length === 0) return null;
206
+
207
+ return [
208
+ { title: '分类', dataIndex: '__x' },
209
+ ...seriesNames.map((name) => ({ title: name, dataIndex: name })),
210
+ ];
211
+ }
212
+
213
+ /**
214
+ * 从 MultiChart code 中读取 chartConfig.table.sortable / .filterable。
215
+ * 三态语义同 readTableFlag。缺省返回 { sortable: true, filterable: true }。
216
+ */
217
+ export function readMultiChartTableFlags(code: string): {
218
+ sortable: SortFilterFlag;
219
+ filterable: SortFilterFlag;
220
+ } {
221
+ const span = findChartConfigSpan(code);
222
+ if (!span) return { sortable: true, filterable: true };
223
+
224
+ let cfg: Record<string, Record<string, unknown>>;
225
+ try {
226
+ cfg = JSON.parse(span.jsonStr);
227
+ } catch {
228
+ return { sortable: true, filterable: true };
229
+ }
230
+
231
+ const tableCfg = cfg.table ?? {};
232
+ const readFlag = (v: unknown): SortFilterFlag => {
233
+ if (v === false) return false;
234
+ if (Array.isArray(v)) return v as string[];
235
+ return true;
236
+ };
237
+
238
+ return {
239
+ sortable: readFlag(tableCfg.sortable),
240
+ filterable: readFlag(tableCfg.filterable),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * 把 sortable / filterable 写回 MultiChart code 的 chartConfig.table。
246
+ * - true → 删除该字段(恢复默认)
247
+ * - false → 写 false
248
+ * - string[] → 写数组
249
+ *
250
+ * 清理空对象:tableCfg 为空 → 删 chartConfig.table;
251
+ * chartConfig 整体为空 → 删 prop(同时吃掉前置的换行与缩进,避免空行)。
252
+ * 保留其它图表的配置(chartConfig.bar / .line 等)不变。
253
+ */
254
+ export function writeMultiChartTableFlags(
255
+ code: string,
256
+ sortable: SortFilterFlag,
257
+ filterable: SortFilterFlag,
258
+ ): string {
259
+ const span = findChartConfigSpan(code);
260
+ let cfg: Record<string, Record<string, unknown>> = {};
261
+ if (span) {
262
+ try {
263
+ cfg = JSON.parse(span.jsonStr);
264
+ } catch {
265
+ cfg = {};
266
+ }
267
+ }
268
+
269
+ const tableCfg = { ...(cfg.table ?? {}) };
270
+
271
+ if (sortable === true) delete tableCfg.sortable;
272
+ else tableCfg.sortable = sortable;
273
+
274
+ if (filterable === true) delete tableCfg.filterable;
275
+ else tableCfg.filterable = filterable;
276
+
277
+ const newCfg: Record<string, Record<string, unknown>> = { ...cfg };
278
+ if (Object.keys(tableCfg).length === 0) {
279
+ delete newCfg.table;
280
+ } else {
281
+ newCfg.table = tableCfg;
282
+ }
283
+
284
+ // chartConfig 整体为空 → 删除整段 prop
285
+ if (Object.keys(newCfg).length === 0) {
286
+ if (!span) return code;
287
+ // 向前吃 ' ' / '\t',以及紧贴的一个 '\n',避免留下空行
288
+ let lead = span.start;
289
+ while (lead > 0 && (code[lead - 1] === ' ' || code[lead - 1] === '\t'))
290
+ lead--;
291
+ if (lead > 0 && code[lead - 1] === '\n') lead--;
292
+ return code.slice(0, lead) + code.slice(span.end);
293
+ }
294
+
295
+ const newCfgStr = `chartConfig={${JSON.stringify(newCfg)}}`;
296
+ if (span) {
297
+ return code.slice(0, span.start) + newCfgStr + code.slice(span.end);
298
+ }
299
+ // 没有 chartConfig prop,注入到 testId 之前
300
+ if (/testId\s*=/.test(code)) {
301
+ return code.replace(/([ \t]*)(testId\s*=)/, `$1${newCfgStr}\n$1$2`);
302
+ }
303
+ return code.replace(/\/>/, ` ${newCfgStr}\n/>`);
304
+ }
305
+
306
+ /**
307
+ * 构造一段虚拟独立 Table JSX,用于喂给 TableConfigModal。
308
+ * Modal 内部走原有 parseTableColumnsMeta / readTableFlag 路径,
309
+ * 完全感知不到这段 code 是合成的。
310
+ *
311
+ * 注意:columns 必须输出双引号字符串字面量,否则 parseTableColumnsMeta
312
+ * 的正则匹配不到 title / dataIndex。
313
+ */
314
+ export function buildVirtualTableCode(
315
+ columns: ColumnMeta[],
316
+ flags: { sortable: SortFilterFlag; filterable: SortFilterFlag },
317
+ ): string {
318
+ const colsStr = columns
319
+ .map(
320
+ (c) =>
321
+ ` { title: ${JSON.stringify(c.title)}, dataIndex: ${JSON.stringify(c.dataIndex)}, key: ${JSON.stringify(c.dataIndex)} }`,
322
+ )
323
+ .join(',\n');
324
+
325
+ const flagToProp = (name: string, flag: SortFilterFlag): string => {
326
+ if (flag === true) return '';
327
+ if (flag === false) return ` ${name}={false}\n`;
328
+ return ` ${name}={${JSON.stringify(flag)}}\n`;
329
+ };
330
+
331
+ return `<Table
332
+ dataSource={[]}
333
+ columns={[
334
+ ${colsStr}
335
+ ]}
336
+ ${flagToProp('sortable', flags.sortable)}${flagToProp('filterable', flags.filterable)} testId="virtual-multichart-table"
337
+ />`;
338
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/schematics",
3
- "version": "0.0.95",
3
+ "version": "0.0.96",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@schematics)",
5
5
  "main": "dist/index.js",
6
6
  "files": [