dsh-api-runner 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -25,7 +25,14 @@ window.__ModuleLoader__.load({
25
25
  /** 面板 id:既是左栏全局页在 `main` 里的 key,也是 `sidebar.panellist` 的 id */
26
26
  const TAB_ID = 'dsh-api-runner';
27
27
  const API = '/dsh-api-runner/api';
28
- const STYLE_ID = 'dsh-api-runner-style';
28
+ /**
29
+ * 样式元素 id 带版本后缀。
30
+ *
31
+ * 为什么:宿主的 styles.insert 和浏览器都会按 id 去重,如果 id 固定,
32
+ * 插件升级后页面里仍是旧 CSS(表现为「改了样式但刷新没用」)。
33
+ * 每次改样式就把版本号 +1,旧元素自然失效,新样式必定注入。
34
+ */
35
+ const STYLE_ID = 'dsh-api-runner-style-v3';
29
36
  const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
30
37
 
31
38
  // ---------------------------------------------------------------- 样式
@@ -36,6 +43,8 @@ window.__ModuleLoader__.load({
36
43
  --ar-accent: var(--dsw-alias-brand-primary, #0a84ff);
37
44
  --ar-surface: rgba(120, 120, 128, .12);
38
45
  --ar-surface-strong: rgba(120, 120, 128, .18);
46
+ /* 选中态用的「抬起」底色:深色主题下比背景亮一点,浅色主题下接近纯白 */
47
+ --ar-elevated: var(--dsw-alias-bg-elevated, rgba(255, 255, 255, .14));
39
48
  --ar-sep: rgba(120, 120, 128, .22);
40
49
  --ar-danger: var(--dsw-alias-state-error-primary, #ff453a);
41
50
  --ar-success: var(--dsw-alias-state-success-primary, #30d158);
@@ -48,25 +57,50 @@ window.__ModuleLoader__.load({
48
57
  }
49
58
  @media (prefers-color-scheme: light) {
50
59
  .ar-root { --ar-surface: rgba(120, 120, 128, .09); --ar-surface-strong: rgba(120, 120, 128, .15);
60
+ --ar-elevated: var(--dsw-alias-bg-elevated, #ffffff);
51
61
  --ar-sep: rgba(60, 60, 67, .16);
52
62
  --ar-danger: var(--dsw-alias-state-error-primary, #ff3b30);
53
63
  --ar-success: var(--dsw-alias-state-success-primary, #34c759);
54
64
  --ar-warn: var(--dsw-alias-state-warn-primary, #ff9f0a); }
55
65
  }
56
66
 
57
- /* ---- 顶部:整条分段控件 ---- */
67
+ /* ---- 顶部:页签胶囊(参照 DSH 插件详情页的分段控件)----
68
+ 两层结构刻意做得不一样,一眼能看出层级:
69
+ · 顶部 ar-topbar:整条浅灰底容器,装四个页签,选中项白底 + 阴影
70
+ · 一级标题 ar-segmented:独立小胶囊,不套外层容器,选中项白底
71
+ */
58
72
  .ar-top { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; padding: 10px 12px 8px; }
59
- .ar-tabs { flex: 1 1 auto; display: flex; gap: 2px; padding: 2px; min-width: 0;
60
- background: var(--ar-surface); border-radius: var(--ar-r-ctrl); }
73
+ /* 窄面板(右侧栏 ~300px)里四个页签 + 刷新按钮会挤,允许容器横向滚动兜底 */
74
+ .ar-tabs { flex: 1 1 auto; display: flex; gap: 2px; padding: 3px; min-width: 0;
75
+ background: var(--ar-surface); border-radius: var(--ar-r-ctrl); overflow-x: auto; scrollbar-width: none; }
76
+ .ar-tabs::-webkit-scrollbar { display: none; }
61
77
  .ar-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
62
78
  border: 0; background: transparent; color: inherit; cursor: pointer; padding: 5px 8px; font: inherit; font-size: 12.5px;
63
- border-radius: var(--ar-r-pill); white-space: nowrap; opacity: .68;
79
+ border-radius: var(--ar-r-pill); white-space: nowrap; opacity: .7;
64
80
  transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease), box-shadow .18s var(--ar-ease); }
65
- .ar-tab:hover { opacity: .9; }
66
- .ar-tab.on { opacity: 1; font-weight: 600; background: var(--ar-surface-strong); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
81
+ .ar-tab:hover { opacity: .92; }
82
+ /* 选中:白底 + 轻阴影 + 深色字,和宿主的分段控件一致 */
83
+ .ar-tab.on { opacity: 1; font-weight: 600; background: var(--ar-elevated); box-shadow: 0 1px 3px rgba(0, 0, 0, .14); }
67
84
  .ar-count { font-size: 10px; font-weight: 600; line-height: 1; padding: 2px 4px; border-radius: 4px;
68
85
  background: var(--ar-surface-strong); opacity: .85; }
69
- .ar-tab.on .ar-count { background: var(--ar-accent); color: #fff; opacity: 1; }
86
+ .ar-tab.on .ar-count { background: var(--ar-surface-strong); color: inherit; opacity: 1; }
87
+
88
+ /* ---- 一级标题:独立小胶囊(参照 DSH 插件详情页的「连接…/项目…/数据…」)---- */
89
+ /* 一级标题胶囊:强制横向一排,绝不换行。
90
+ 面板很窄(右侧栏只有 ~300px),一旦允许 wrap 就会被挤成竖排——
91
+ 参照宿主插件详情页的做法:横向滚动,胶囊本身不压缩。 */
92
+ .ar-segmented { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap;
93
+ padding: 2px 0 10px; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
94
+ .ar-segmented::-webkit-scrollbar { display: none; }
95
+ .ar-seg { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px; border: 0; cursor: pointer;
96
+ padding: 5px 12px; font: inherit; font-size: 12.5px; white-space: nowrap;
97
+ background: var(--ar-surface); color: inherit; border-radius: var(--ar-r-pill); opacity: .82;
98
+ transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease), box-shadow .18s var(--ar-ease); }
99
+ .ar-seg:hover { opacity: 1; background: var(--ar-surface-strong); }
100
+ .ar-seg.on { opacity: 1; font-weight: 600; background: var(--ar-elevated);
101
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .14), 0 0 0 .5px rgba(0, 0, 0, .04); }
102
+ .ar-seg .n { font-size: 10.5px; font-weight: 600; opacity: .55; }
103
+ .ar-seg.on .n { opacity: .8; }
70
104
 
71
105
  .ar-body { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding: 2px 12px 24px;
72
106
  display: flex; flex-direction: column; gap: 10px; }
@@ -91,7 +125,11 @@ window.__ModuleLoader__.load({
91
125
  注意:选中态用实心 accent 而不是半透明混合,避免依赖 color-mix,浅色/深色主题都稳。 */
92
126
  .ar-tabs.sub { flex: none; background: transparent; padding: 0; gap: 4px; }
93
127
  .ar-tabs.sub .ar-tab { padding: 4px 8px; font-size: 12px; background: var(--ar-surface); opacity: .72; }
94
- .ar-tabs.sub .ar-tab:hover { opacity: .95; }
128
+ /* 二级页签(已接管/运行中/…、项目分类):改成和一级标题同款的小胶囊,
129
+ 但用「品牌色实心」区分层级,不和顶部那排白底选中块混淆。 */
130
+ .ar-tabs.sub { flex: none; background: transparent; padding: 0; gap: 6px; }
131
+ .ar-tabs.sub .ar-tab { flex: 0 0 auto; padding: 5px 11px; font-size: 12px; background: var(--ar-surface); opacity: .82; }
132
+ .ar-tabs.sub .ar-tab:hover { opacity: 1; }
95
133
  .ar-tabs.sub .ar-tab.on {
96
134
  background: var(--ar-accent); color: #fff; opacity: 1; font-weight: 600;
97
135
  box-shadow: 0 1px 3px rgba(0, 0, 0, .18);
@@ -207,6 +245,17 @@ window.__ModuleLoader__.load({
207
245
  .ar-empty-title { font-size: 13px; font-weight: 600; }
208
246
  .ar-empty-hint { font-size: 12px; opacity: .55; line-height: 1.6; max-width: 230px; }
209
247
 
248
+ /* ---- 数据库:结果表格 ---- */
249
+ .ar-item-on { box-shadow: inset 2px 0 0 var(--ar-accent); }
250
+ .ar-table-wrap { width: 100%; overflow-x: auto; border-radius: var(--ar-r-ctrl); background: var(--ar-surface); }
251
+ .ar-table { border-collapse: collapse; width: 100%; font-size: 12px; font-family: var(--ar-mono); }
252
+ .ar-table th { text-align: left; font-weight: 600; padding: 6px 9px; white-space: nowrap;
253
+ border-bottom: 1px solid var(--ar-sep); opacity: .7; position: sticky; top: 0; background: var(--ar-surface-strong); }
254
+ .ar-table td { padding: 5px 9px; border-bottom: 1px solid var(--ar-sep); white-space: nowrap; max-width: 320px;
255
+ overflow: hidden; text-overflow: ellipsis; }
256
+ .ar-table tbody tr:last-child td { border-bottom: none; }
257
+ .ar-table tbody tr:hover td { background: var(--ar-surface-strong); }
258
+
210
259
  /* ---- 一键启动进度 ---- */
211
260
  .ar-batch { display: flex; flex-direction: column; gap: 6px; padding: 9px 11px; border-radius: var(--ar-r-card);
212
261
  background: var(--ar-surface); }
@@ -248,22 +297,25 @@ window.__ModuleLoader__.load({
248
297
 
249
298
  /**
250
299
  * 插入样式表。
251
- * 优先用宿主给动态 Client half 的 styles.insert(由 DSH 管理生命周期、
252
- * 保证插到正确的文档根);取不到才退回 document.head,与 dsh-db-tool 同法。
300
+ *
301
+ * 不再依赖宿主的 styles.insert:它按插件 id 去重,插件升级后页面里
302
+ * 仍是旧 CSS(胶囊退化成原生 button、竖排),刷新也无效。
303
+ * 现在自己维护 <style>,并**先移除所有旧版本元素**再插入,
304
+ * 保证每次加载都是当前这份样式。
253
305
  */
254
- function installStyles(ctx) {
255
- let api;
256
- if (typeof styles !== 'undefined' && styles && typeof styles.insert === 'function') api = styles;
257
- else if (ctx && typeof ctx.get === 'function') api = ctx.get('styles');
258
- if (api && typeof api.insert === 'function') {
259
- api.insert(CSS);
260
- return;
306
+ function installStyles() {
307
+ const head = document.head || document.documentElement;
308
+ // 清掉历史版本(含旧的无版本号 id),避免旧规则覆盖新规则
309
+ for (const node of document.querySelectorAll('style[id^="dsh-api-runner-style"]')) {
310
+ if (node.id !== STYLE_ID) node.remove();
311
+ }
312
+ let el = document.getElementById(STYLE_ID);
313
+ if (el === null) {
314
+ el = document.createElement('style');
315
+ el.id = STYLE_ID;
316
+ head.appendChild(el);
261
317
  }
262
- if (document.getElementById(STYLE_ID)) return;
263
- const el = document.createElement('style');
264
- el.id = STYLE_ID;
265
- el.textContent = CSS;
266
- (document.head || document.documentElement).appendChild(el);
318
+ if (el.textContent !== CSS) el.textContent = CSS;
267
319
  }
268
320
 
269
321
  // ---------------------------------------------------------------- 工具
@@ -611,6 +663,174 @@ window.__ModuleLoader__.load({
611
663
  : null);
612
664
  }
613
665
 
666
+ /**
667
+ * 数据库连接编辑表单。
668
+ *
669
+ * 密码框用 type=password 且编辑时不回填:服务端只回传掩码,
670
+ * 留空表示「不修改密码」,避免编辑别处字段时把密码清掉。
671
+ */
672
+ function EditDbForm(props) {
673
+ const { value, onChange, onSave, onCancel, busy } = props;
674
+ const set = (key, next) => onChange({ ...value, [key]: next });
675
+ const isSqlite = value.type === 'sqlite';
676
+ const needsHost = !isSqlite;
677
+ return h('div', { className: 'ar-card' },
678
+ h('div', { className: 'ar-name' }, value.id ? `编辑连接 ${value.name}` : '新增数据库连接'),
679
+ h(Field, { label: '名称' }, h('input', {
680
+ className: 'ar-in', value: value.name, placeholder: '如 本地 MySQL',
681
+ onChange: (e) => set('name', e.target.value),
682
+ })),
683
+ h(Field, { label: '类型' }, h('select', {
684
+ className: 'ar-sel', value: value.type,
685
+ onChange: (e) => set('type', e.target.value),
686
+ }, ['sqlite', 'mysql', 'postgres', 'oracle', 'dm'].map((t) => h('option', { key: t, value: t }, t)))),
687
+ isSqlite
688
+ ? h(Field, { label: '文件路径' }, h('input', {
689
+ className: 'ar-in ar-mono', value: value.database,
690
+ placeholder: ':memory: 或 D:\\data\\app.db',
691
+ onChange: (e) => set('database', e.target.value),
692
+ }))
693
+ : h('div', null,
694
+ h('div', { className: 'ar-row' },
695
+ h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
696
+ h(Field, { label: 'host' }, h('input', {
697
+ className: 'ar-in ar-mono', value: value.host, placeholder: '127.0.0.1',
698
+ onChange: (e) => set('host', e.target.value),
699
+ }))),
700
+ h('div', { style: { flex: '0 0 90px' } },
701
+ h(Field, { label: '端口' }, h('input', {
702
+ className: 'ar-in ar-mono', value: value.port, placeholder: '默认',
703
+ onChange: (e) => set('port', e.target.value),
704
+ })))),
705
+ h(Field, { label: '数据库' }, h('input', {
706
+ className: 'ar-in ar-mono', value: value.database, placeholder: '库名',
707
+ onChange: (e) => set('database', e.target.value),
708
+ })),
709
+ h(Field, { label: '用户名' }, h('input', {
710
+ className: 'ar-in', value: value.username, placeholder: 'root',
711
+ onChange: (e) => set('username', e.target.value),
712
+ })),
713
+ h(Field, { label: '密码' }, h('input', {
714
+ className: 'ar-in', type: 'password', value: value.password,
715
+ placeholder: value.id ? '留空表示不修改' : '',
716
+ onChange: (e) => set('password', e.target.value),
717
+ }))),
718
+ h(Field, { label: '额外参数', top: true }, h('textarea', {
719
+ className: 'ar-ta ar-mono', value: value.extraText,
720
+ placeholder: '可选,JSON:{"ssl":true}',
721
+ onChange: (e) => set('extraText', e.target.value),
722
+ })),
723
+ h('div', { className: 'ar-hint' }, '密码会加密后保存,列表与工具输出只显示掩码。'),
724
+ h('div', { className: 'ar-row' },
725
+ h('button', {
726
+ className: 'ar-btn primary',
727
+ disabled: Boolean(busy),
728
+ onClick: () => {
729
+ // 额外参数是 JSON,解析失败就拦在这里,别把坏值发到后端
730
+ let extra;
731
+ const text = String(value.extraText || '').trim();
732
+ if (text !== '') {
733
+ try {
734
+ extra = JSON.parse(text);
735
+ } catch (error) {
736
+ window.alert(`额外参数不是合法 JSON:${error.message}`);
737
+ return;
738
+ }
739
+ }
740
+ onSave({
741
+ id: value.id || undefined,
742
+ name: value.name,
743
+ type: value.type,
744
+ host: value.host,
745
+ port: value.port === '' ? undefined : Number(value.port),
746
+ database: value.database,
747
+ username: value.username,
748
+ // 空密码不传,让后端保留原值
749
+ password: value.password === '' ? undefined : value.password,
750
+ extra,
751
+ });
752
+ },
753
+ }, '保存'),
754
+ h('button', { className: 'ar-btn ghost', onClick: onCancel }, '取消')));
755
+ }
756
+
757
+ /** SQL 执行结果:表格 + 行数/耗时 */
758
+ function DbResultCard(props) {
759
+ const { result, onClose } = props;
760
+ if (!result) return null;
761
+ if (result.ok === false) {
762
+ return h('div', { className: 'ar-card' },
763
+ h('div', { className: 'ar-row' },
764
+ h('span', { className: 'ar-badge', style: { background: 'var(--ar-danger)', color: '#fff' } }, result.code || 'ERROR'),
765
+ h('span', { className: 'ar-spacer' }),
766
+ h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
767
+ h('div', { className: 'ar-hint ar-mono', style: { overflowWrap: 'anywhere' } }, result.message || '执行失败'));
768
+ }
769
+ const columns = Array.isArray(result.columns) ? result.columns : [];
770
+ const rows = Array.isArray(result.rows) ? result.rows : [];
771
+ const affected = result.affectedRows;
772
+ return h('div', { className: 'ar-card' },
773
+ h('div', { className: 'ar-row' },
774
+ h('span', { className: 'ar-badge ok' }, 'OK'),
775
+ h('span', { className: 'ar-hint' }, `${result.rowCount} 行 · ${result.durationMs}ms`),
776
+ result.truncated ? h('span', { className: 'ar-badge', style: { background: 'var(--ar-warn)' } }, `已截断至 ${result.maxRows}`) : null,
777
+ h('span', { className: 'ar-spacer' }),
778
+ h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
779
+ typeof affected === 'number' && affected > 0
780
+ ? h('div', { className: 'ar-hint' }, `影响 ${affected} 行`)
781
+ : null,
782
+ columns.length > 0 && rows.length > 0
783
+ ? h('div', { className: 'ar-table-wrap' },
784
+ h('table', { className: 'ar-table' },
785
+ h('thead', null, h('tr', null, columns.map((c) => h('th', { key: c }, c)))),
786
+ h('tbody', null, rows.map((row, index) => h('tr', { key: index },
787
+ columns.map((c) => h('td', { key: c }, formatCell(row[c]))))))))
788
+ : h('div', { className: 'ar-hint' }, rows.length === 0 ? '查询成功,没有数据行。' : ''));
789
+ }
790
+
791
+ /** 单元格渲染:对象/数组转 JSON,null 显示为灰色占位 */
792
+ function formatCell(value) {
793
+ if (value === null) return h('span', { className: 'ar-faint' }, 'NULL');
794
+ if (value === undefined) return '';
795
+ if (typeof value === 'object') {
796
+ try {
797
+ return JSON.stringify(value);
798
+ } catch {
799
+ return String(value);
800
+ }
801
+ }
802
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
803
+ return String(value);
804
+ }
805
+
806
+ /** 表结构:字段表 + 索引 */
807
+ function TableDetail(props) {
808
+ const { detail, onClose } = props;
809
+ const columns = Array.isArray(detail.columns) ? detail.columns : [];
810
+ const indexes = Array.isArray(detail.indexes) ? detail.indexes : [];
811
+ return h('div', { className: 'ar-card' },
812
+ h('div', { className: 'ar-row' },
813
+ h('span', { className: 'ar-name ar-mono' }, detail.table),
814
+ h('span', { className: 'ar-spacer' }),
815
+ h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
816
+ h('div', { className: 'ar-table-wrap' },
817
+ h('table', { className: 'ar-table' },
818
+ h('thead', null, h('tr', null,
819
+ h('th', null, '字段'), h('th', null, '类型'), h('th', null, '可空'), h('th', null, '默认'), h('th', null, '主键'))),
820
+ h('tbody', null, columns.map((c) => h('tr', { key: c.name },
821
+ h('td', { className: 'ar-mono' }, c.name),
822
+ h('td', { className: 'ar-mono ar-muted' }, c.type || ''),
823
+ h('td', null, c.nullable ? 'Y' : ''),
824
+ h('td', { className: 'ar-mono ar-muted' }, c.defaultValue === null || c.defaultValue === undefined ? '' : String(c.defaultValue)),
825
+ h('td', null, c.primaryKey ? 'PK' : '')))))),
826
+ indexes.length > 0
827
+ ? h('div', null,
828
+ h('div', { className: 'ar-sec' }, h('span', { className: 't' }, `索引 · ${indexes.length}`)),
829
+ indexes.map((i) => h('div', { key: i.name, className: 'ar-hint ar-mono ar-cut' },
830
+ `${i.name}${i.unique ? ' (unique)' : ''}${Array.isArray(i.columns) ? ` · ${i.columns.join(', ')}` : ''}${i.definition ? ` · ${i.definition}` : ''}`)))
831
+ : null);
832
+ }
833
+
614
834
  function SavedRow(props) {
615
835
  const { item, busy, onStart, onDelete } = props;
616
836
  return h('div', { className: 'ar-item' },
@@ -888,6 +1108,19 @@ window.__ModuleLoader__.load({
888
1108
  const [mavenHomeDraft, setMavenHomeDraft] = React.useState('');
889
1109
  /** 一键启动的后台批次进度(null = 没有正在进行的批次) */
890
1110
  const [batch, setBatch] = React.useState(null);
1111
+ /** 数据库:连接列表 / 选中的连接 / 执行结果 / 表清单 */
1112
+ const [dbConnections, setDbConnections] = React.useState([]);
1113
+ const [dbError, setDbError] = React.useState('');
1114
+ const [dbActive, setDbActive] = React.useState('');
1115
+ const [dbForm, setDbForm] = React.useState(null);
1116
+ const [dbSql, setDbSql] = React.useState('');
1117
+ const [dbResult, setDbResult] = React.useState(null);
1118
+ const [dbReadonly, setDbReadonly] = React.useState(true);
1119
+ const [dbTables, setDbTables] = React.useState([]);
1120
+ const [dbTableDetail, setDbTableDetail] = React.useState(null);
1121
+ const [dbDbBusy, setDbDbBusy] = React.useState('');
1122
+ /** 数据库子页的一级标题:conn(连接)/ sql / schema(表结构) */
1123
+ const [dbSection, setDbSection] = React.useState('conn');
891
1124
 
892
1125
  const headers = React.useMemo(() => ({ 'Content-Type': 'application/json', 'x-dsh-session-id': sessionId }), [sessionId]);
893
1126
  const get = React.useCallback(async (path) => {
@@ -1318,9 +1551,142 @@ window.__ModuleLoader__.load({
1318
1551
  }
1319
1552
  };
1320
1553
 
1554
+ // ---------------------------------------------------------------- 数据库
1555
+ /**
1556
+ * 数据库连接是跨项目的(同一套库常被多个项目共用),
1557
+ * 所以这里不跟着「接口」页的项目页签走,始终操作全局连接列表。
1558
+ */
1559
+ const refreshDb = React.useCallback(async () => {
1560
+ try {
1561
+ const data = await request('db', { headers });
1562
+ if (data.ok === false) {
1563
+ setDbError(data.message || '读取数据库连接失败');
1564
+ setDbConnections([]);
1565
+ return;
1566
+ }
1567
+ const list = Array.isArray(data.connections) ? data.connections : [];
1568
+ setDbConnections(list);
1569
+ setDbError('');
1570
+ // 选中的连接被删了就退回第一个
1571
+ setDbActive((current) => (list.some((item) => item.id === current) ? current : (list[0] ? list[0].id : '')));
1572
+ } catch (e) {
1573
+ setDbError(e.message);
1574
+ }
1575
+ }, [headers]);
1576
+
1577
+ /**
1578
+ * 数据库连接只在面板可见时加载一次。
1579
+ *
1580
+ * 注意:这个 effect 必须放在 refreshDb 声明**之后**。
1581
+ * 之前把它并进上面那个主 effect 的依赖数组,而那个 effect 在 refreshDb
1582
+ * 之前执行,const 的暂时性死区直接抛 `Cannot access 'refreshDb' before
1583
+ * initialization`,整个 Panel 崩掉 -> 面板白屏。
1584
+ */
1585
+ React.useEffect(() => {
1586
+ if (!visible) return undefined;
1587
+ refreshDb();
1588
+ return undefined;
1589
+ }, [visible, refreshDb]);
1590
+
1591
+ const loadDbTables = React.useCallback(async (connectionId) => {
1592
+ if (!connectionId) { setDbTables([]); return; }
1593
+ try {
1594
+ const data = await request(`db/tables?connection_id=${encodeURIComponent(connectionId)}`, { headers });
1595
+ setDbTables(data.ok === false ? [] : (Array.isArray(data.tables) ? data.tables : []));
1596
+ if (data.ok === false) setDbError(data.message || '读取表清单失败');
1597
+ } catch (e) {
1598
+ setDbError(e.message);
1599
+ }
1600
+ }, [headers]);
1601
+
1602
+ const dbSave = async (value) => {
1603
+ setDbDbBusy('save');
1604
+ setDbError('');
1605
+ try {
1606
+ const payload = await post('db/save', { connection: value });
1607
+ if (payload.ok === false) throw new Error(payload.message || '保存失败');
1608
+ setDbForm(null);
1609
+ setNotice(payload.message || '连接已保存');
1610
+ await refreshDb();
1611
+ } catch (e) {
1612
+ setDbError(e.message);
1613
+ } finally {
1614
+ setDbDbBusy('');
1615
+ }
1616
+ };
1617
+
1618
+ const dbDelete = async (item) => {
1619
+ if (!window.confirm(`删除连接「${item.name}」?(只删除配置,不影响数据库本身)`)) return;
1620
+ setDbDbBusy('delete');
1621
+ try {
1622
+ const payload = await post('db/delete', { connection_id: item.id });
1623
+ if (payload.ok === false) throw new Error(payload.message || '删除失败');
1624
+ setNotice(payload.message || '已删除');
1625
+ setDbResult(null);
1626
+ setDbTables([]);
1627
+ await refreshDb();
1628
+ } catch (e) {
1629
+ setDbError(e.message);
1630
+ } finally {
1631
+ setDbDbBusy('');
1632
+ }
1633
+ };
1634
+
1635
+ const dbTest = async (item) => {
1636
+ setDbDbBusy('test');
1637
+ setDbError('');
1638
+ try {
1639
+ const payload = await post('db/test', { connection_id: item.id });
1640
+ if (payload.ok) setNotice(`连接「${item.name}」正常(${payload.durationMs}ms)`);
1641
+ else setDbError(payload.message || '连接失败');
1642
+ } catch (e) {
1643
+ setDbError(e.message);
1644
+ } finally {
1645
+ setDbDbBusy('');
1646
+ }
1647
+ };
1648
+
1649
+ const dbRun = async () => {
1650
+ if (!dbActive) { setDbError('请先选择一个连接'); return; }
1651
+ if (String(dbSql).trim() === '') { setDbError('SQL 不能为空'); return; }
1652
+ setDbDbBusy('query');
1653
+ setDbError('');
1654
+ setDbResult(null);
1655
+ try {
1656
+ const payload = await post('db/query', {
1657
+ connection_id: dbActive,
1658
+ sql: dbSql,
1659
+ readonly: dbReadonly,
1660
+ });
1661
+ setDbResult(payload);
1662
+ if (payload.ok === false) setDbError(payload.message || '执行失败');
1663
+ } catch (e) {
1664
+ setDbError(e.message);
1665
+ } finally {
1666
+ setDbDbBusy('');
1667
+ }
1668
+ };
1669
+
1670
+ const dbDescribe = async (tableName) => {
1671
+ if (!dbActive || !tableName) return;
1672
+ setDbDbBusy('describe');
1673
+ try {
1674
+ const data = await request(`db/table?connection_id=${encodeURIComponent(dbActive)}&name=${encodeURIComponent(tableName)}`, { headers });
1675
+ if (data.ok === false) { setDbError(data.message || '读取表结构失败'); return; }
1676
+ setDbTableDetail(data);
1677
+ // 点表名顺手把查询填好,省得手打
1678
+ setDbSql(`SELECT * FROM ${tableName} LIMIT 100`);
1679
+ } catch (e) {
1680
+ setDbError(e.message);
1681
+ } finally {
1682
+ setDbDbBusy('');
1683
+ }
1684
+ };
1685
+
1321
1686
  const tabs = [
1322
1687
  { id: 'apis', label: '接口', count: apis.length },
1323
1688
  { id: 'services', label: '服务', count: runningList.length },
1689
+ { id: 'db', label: '数据库', count: dbConnections.length },
1324
1690
  { id: 'settings', label: '设置', count: 0 },
1325
1691
  ];
1326
1692
 
@@ -1578,6 +1944,115 @@ window.__ModuleLoader__.load({
1578
1944
  ? state.sources.map((s) => h('div', { key: s, className: 'ar-kv' }, h('span', { className: 'k' }, '来源'), h('span', { className: 'v ar-mono ar-muted ar-cut', title: s }, s)))
1579
1945
  : h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '来源'), h('span', { className: 'v ar-muted' }, '暂无 YAML 文件,接口全部来自面板')))));
1580
1946
 
1947
+ // ---------------------------------------------------------------- 数据库面板
1948
+ const activeConn = dbConnections.find((item) => item.id === dbActive) || null;
1949
+ /** 数据库子页的一级标题:独立小胶囊,一次只看一块(和参考图的「连接…/SQL…/数据…」一致) */
1950
+ const dbSections = [
1951
+ { id: 'conn', label: '连接', count: dbConnections.length },
1952
+ { id: 'sql', label: 'SQL', count: 0 },
1953
+ { id: 'schema', label: '表结构', count: dbTables.length },
1954
+ ];
1955
+ const dbPane = h('div', { className: 'ar-pane' },
1956
+ h(Banner, { kind: 'err', text: dbError, onClose: () => setDbError('') }),
1957
+
1958
+ h('div', { className: 'ar-segmented' }, dbSections.map((item) => h('button', {
1959
+ key: item.id,
1960
+ className: item.id === dbSection ? 'ar-seg on' : 'ar-seg',
1961
+ onClick: () => setDbSection(item.id),
1962
+ ref: (node) => {
1963
+ // 窄面板里胶囊会横向滚动,选中的那个必须自己滚进视野
1964
+ if (node && item.id === dbSection) node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1965
+ },
1966
+ }, item.label, item.count > 0 ? h('span', { className: 'n' }, item.count) : null))),
1967
+
1968
+ // ---- 连接 ----
1969
+ dbSection === 'conn' ? h('div', { className: 'ar-pane' },
1970
+ h('div', { className: 'ar-row' },
1971
+ h('button', {
1972
+ className: 'ar-btn tonal',
1973
+ disabled: dbDbBusy !== '',
1974
+ onClick: () => setDbForm({
1975
+ id: '', name: '', type: 'sqlite', host: '', port: '',
1976
+ database: '', username: '', password: '', extraText: '',
1977
+ }),
1978
+ }, '新增连接'),
1979
+ h('span', { className: 'ar-spacer' }),
1980
+ h('button', { className: 'ar-btn ghost icon', disabled: dbDbBusy !== '', title: '重新读取连接列表', onClick: refreshDb }, '↻')),
1981
+
1982
+ dbConnections.length === 0
1983
+ ? h(Empty, {
1984
+ icon: '🗄',
1985
+ title: '还没有数据库连接',
1986
+ hint: '点「新增连接」配一个。SQLite 直接填文件路径(:memory: 表示内存库);MySQL / PostgreSQL / Oracle / 达梦填 host、端口、账号密码。',
1987
+ })
1988
+ : h('div', { className: 'ar-group' }, dbConnections.map((item) => h('div', {
1989
+ key: item.id,
1990
+ className: item.id === dbActive ? 'ar-item ar-item-on' : 'ar-item',
1991
+ onClick: () => { setDbActive(item.id); setDbResult(null); setDbTableDetail(null); loadDbTables(item.id); },
1992
+ },
1993
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
1994
+ h('span', { className: 'ar-badge brand' }, item.type),
1995
+ h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name),
1996
+ item.id === dbActive ? h('span', { className: 'ar-badge' }, '当前') : null),
1997
+ h('div', { className: 'ar-hint ar-mono ar-cut', title: item.type === 'sqlite' ? item.file : `${item.host}:${item.port}/${item.database}` },
1998
+ item.type === 'sqlite' ? (item.file || ':memory:') : `${item.host}:${item.port}/${item.database}`),
1999
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
2000
+ h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '', onClick: () => dbTest(item) }, '测试'),
2001
+ h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '', onClick: () => setDbForm({
2002
+ id: item.id, name: item.name, type: item.type, host: item.host,
2003
+ port: item.port ? String(item.port) : '', database: item.database,
2004
+ username: item.username, password: '', extraText: Object.keys(item.extra || {}).length ? JSON.stringify(item.extra, null, 2) : '',
2005
+ }) }, '编辑'),
2006
+ h('span', { className: 'ar-spacer' }),
2007
+ h('button', { className: 'ar-btn ghost danger', disabled: dbDbBusy !== '', onClick: () => dbDelete(item) }, '删除'))))),
2008
+
2009
+ dbForm ? h(EditDbForm, { value: dbForm, onChange: setDbForm, onSave: dbSave, onCancel: () => setDbForm(null), busy: dbDbBusy }) : null)
2010
+ : null,
2011
+
2012
+ // ---- SQL ----
2013
+ dbSection === 'sql' ? h('div', { className: 'ar-pane' },
2014
+ h('div', { className: 'ar-card' },
2015
+ h('div', { className: 'ar-hint' }, activeConn
2016
+ ? `当前连接:${activeConn.name}(${activeConn.type})`
2017
+ : '先到「连接」里选一个连接'),
2018
+ h('textarea', {
2019
+ className: 'ar-ta ar-mono',
2020
+ value: dbSql,
2021
+ placeholder: 'SELECT * FROM table LIMIT 100',
2022
+ spellCheck: false,
2023
+ onChange: (e) => setDbSql(e.target.value),
2024
+ style: { minHeight: '90px' },
2025
+ }),
2026
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
2027
+ h('label', { className: 'ar-check' },
2028
+ h('input', { type: 'checkbox', checked: dbReadonly, onChange: (e) => setDbReadonly(e.target.checked) }),
2029
+ h('span', {}, '只读模式(拒绝写操作)')),
2030
+ h('span', { className: 'ar-spacer' }),
2031
+ h('button', { className: 'ar-btn primary', disabled: dbDbBusy !== '' || !dbActive, onClick: dbRun },
2032
+ dbDbBusy === 'query' ? '执行中…' : '执行')),
2033
+ dbReadonly ? null : h('div', { className: 'ar-hint', style: { color: 'var(--ar-warn)' } }, '只读模式已关闭:写操作会真实生效。')),
2034
+
2035
+ dbResult ? h(DbResultCard, { result: dbResult, onClose: () => setDbResult(null) }) : null)
2036
+ : null,
2037
+
2038
+ // ---- 表结构 ----
2039
+ dbSection === 'schema' ? h('div', { className: 'ar-pane' },
2040
+ h('div', { className: 'ar-row' },
2041
+ h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '' || !dbActive, onClick: () => loadDbTables(dbActive) }, '刷新表清单'),
2042
+ h('span', { className: 'ar-hint', style: { flex: '1 1 auto', minWidth: 0 } },
2043
+ dbTables.length > 0 ? `${dbTables.length} 个表` : '(未加载)')),
2044
+ dbTables.length === 0
2045
+ ? h('div', { className: 'ar-hint' }, dbActive ? '点「刷新表清单」查看这个库里的表。' : '先到「连接」里选一个连接。')
2046
+ : h('div', { className: 'ar-group' }, dbTables.map((item) => h('button', {
2047
+ key: item.name,
2048
+ className: 'ar-btn ghost ar-mono',
2049
+ style: { justifyContent: 'flex-start', width: '100%' },
2050
+ disabled: dbDbBusy !== '',
2051
+ onClick: () => dbDescribe(item.name),
2052
+ }, item.name))),
2053
+ dbTableDetail ? h(TableDetail, { detail: dbTableDetail, onClose: () => setDbTableDetail(null) }) : null)
2054
+ : null);
2055
+
1581
2056
  return h('div', { className: 'ar-root' },
1582
2057
  h('div', { className: 'ar-top' },
1583
2058
  h('div', { className: 'ar-tabs' }, tabs.map((item) => h('button', {
@@ -1591,6 +2066,7 @@ window.__ModuleLoader__.load({
1591
2066
  h(Banner, { kind: 'ok', text: error ? '' : notice, onClose: () => setNotice('') }),
1592
2067
  tab === 'apis' ? apisPane : null,
1593
2068
  tab === 'services' ? servicesPane : null,
2069
+ tab === 'db' ? dbPane : null,
1594
2070
  tab === 'settings' ? settingsPane : null));
1595
2071
  }
1596
2072
 
@@ -1773,7 +2249,7 @@ window.__ModuleLoader__.load({
1773
2249
  }
1774
2250
 
1775
2251
  function apply(ctx) {
1776
- installStyles(ctx);
2252
+ installStyles();
1777
2253
  const slots = typeof ctx.get === 'function' ? ctx.get('slots') : undefined;
1778
2254
  if (!slots || typeof slots.register !== 'function') return;
1779
2255
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-api-runner",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "DSH 插件:按配置调用后端 HTTP 接口、启停/重启本地开发服务、增量读取日志并等待日志特征,让模型能\"改完自己验\"。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,12 +32,12 @@
32
32
  "./package.json": "./package.json"
33
33
  },
34
34
  "engines": {
35
- "node": ">=20",
35
+ "node": ">=22.5",
36
36
  "dsh": ">=0.1.0-rc.6 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
37
37
  },
38
38
  "scripts": {
39
- "test": "node test/smoke.mjs && node test/plugin.mjs",
40
- "test:all": "node test/smoke.mjs && node test/plugin.mjs && node test/client.mjs && node test/managed.mjs && node test/java.mjs",
39
+ "test": "node test/smoke.mjs && node test/plugin.mjs && node test/db.mjs",
40
+ "test:all": "node test/smoke.mjs && node test/plugin.mjs && node test/db.mjs && node test/client.mjs && node test/managed.mjs && node test/java.mjs",
41
41
  "stage": "node scripts/stage.mjs",
42
42
  "release": "node scripts/stage.mjs --release"
43
43
  },