dsh-api-runner 0.2.1 → 0.3.1

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,245 +25,436 @@ 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-v5';
29
36
  const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
30
37
 
38
+ /** 数据库类型下拉项:value 传给后端,label 给人看 */
39
+ const DB_TYPE_OPTIONS = [
40
+ { value: 'sqlite', label: 'SQLite' },
41
+ { value: 'mysql', label: 'MySQL' },
42
+ { value: 'postgres', label: 'PostgreSQL' },
43
+ { value: 'oracle', label: 'Oracle' },
44
+ { value: 'dm', label: '达梦 DM' },
45
+ ];
46
+ /** 各类型默认端口,只用于输入框占位提示 */
47
+ const DEFAULT_PORT_HINT = {
48
+ mysql: 3306,
49
+ postgres: 5432,
50
+ oracle: 1521,
51
+ dm: 5236,
52
+ };
53
+
31
54
  // ---------------------------------------------------------------- 样式
32
55
  const CSS = `
33
- .ar-root { display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; box-sizing: border-box;
34
- font-size: 13px; line-height: 1.45; -webkit-font-smoothing: antialiased;
35
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
36
- --ar-accent: var(--dsw-alias-brand-primary, #0a84ff);
37
- --ar-surface: rgba(120, 120, 128, .12);
38
- --ar-surface-strong: rgba(120, 120, 128, .18);
39
- --ar-sep: rgba(120, 120, 128, .22);
40
- --ar-danger: var(--dsw-alias-state-error-primary, #ff453a);
41
- --ar-success: var(--dsw-alias-state-success-primary, #30d158);
42
- --ar-warn: var(--dsw-alias-state-warn-primary, #ffd60a);
43
- --ar-r-card: 12px;
44
- --ar-r-ctrl: 8px;
45
- --ar-r-pill: 6px;
46
- --ar-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
47
- --ar-ease: cubic-bezier(.25, .1, .25, 1);
48
- }
49
- @media (prefers-color-scheme: light) {
50
- .ar-root { --ar-surface: rgba(120, 120, 128, .09); --ar-surface-strong: rgba(120, 120, 128, .15);
51
- --ar-sep: rgba(60, 60, 67, .16);
52
- --ar-danger: var(--dsw-alias-state-error-primary, #ff3b30);
53
- --ar-success: var(--dsw-alias-state-success-primary, #34c759);
54
- --ar-warn: var(--dsw-alias-state-warn-primary, #ff9f0a); }
55
- }
56
-
57
- /* ---- 顶部:整条分段控件 ---- */
58
- .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); }
61
- .ar-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
62
- 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;
64
- 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); }
67
- .ar-count { font-size: 10px; font-weight: 600; line-height: 1; padding: 2px 4px; border-radius: 4px;
68
- background: var(--ar-surface-strong); opacity: .85; }
69
- .ar-tab.on .ar-count { background: var(--ar-accent); color: #fff; opacity: 1; }
70
-
71
- .ar-body { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding: 2px 12px 24px;
72
- display: flex; flex-direction: column; gap: 10px; }
73
- .ar-pane { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
74
-
75
- /* ---- 排版 ---- */
76
- .ar-mono { font-family: var(--ar-mono); }
77
- .ar-muted { opacity: .6; }
78
- .ar-faint { opacity: .45; }
79
- .ar-cut { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
80
- .ar-spacer { flex: 1 1 auto; min-width: 0; }
81
- .ar-row { display: flex; align-items: center; gap: 6px; }
82
-
83
- /* 分组标题:一行小标题 + 右延细线 */
84
- .ar-sec { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
85
- .ar-sec .t { font-size: 12.5px; font-weight: 600; letter-spacing: .01em; opacity: .72; white-space: nowrap; }
86
- .ar-sec::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--ar-sep); }
87
-
88
- /* 二级页签:和顶部同一套分段控件,但用颜色区分层级——
89
- 顶部(接口/服务/设置)是灰底 + 灰白选中块,二级(已接管/运行中/…、项目分类)去掉灰底容器、
90
- 选中态用品牌色实心块。这样一眼能看出「当前在哪一层」,不会和顶部混成同一排。
91
- 注意:选中态用实心 accent 而不是半透明混合,避免依赖 color-mix,浅色/深色主题都稳。 */
92
- .ar-tabs.sub { flex: none; background: transparent; padding: 0; gap: 4px; }
93
- .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; }
95
- .ar-tabs.sub .ar-tab.on {
96
- background: var(--ar-accent); color: #fff; opacity: 1; font-weight: 600;
97
- box-shadow: 0 1px 3px rgba(0, 0, 0, .18);
98
- }
99
- .ar-tabs.sub .ar-count { font-size: 9.5px; padding: 1px 4px; background: var(--ar-surface-strong); opacity: .9; }
100
- .ar-tabs.sub .ar-tab.on .ar-count { background: rgba(255, 255, 255, .26); color: #fff; opacity: 1; }
101
- /* 项目多的时候横向滚动,不换行、不压扁页签 */
102
- .ar-tabs.ar-projects { overflow-x: auto; scrollbar-width: none; }
103
- .ar-tabs.ar-projects::-webkit-scrollbar { display: none; }
104
- .ar-tabs.ar-projects .ar-tab { flex: 0 0 auto; max-width: 132px; }
105
- .ar-tabs.ar-projects .ar-tab .l { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
106
-
107
- /* ---- inset grouped 卡片 ---- */
108
- .ar-card { display: flex; flex-direction: column; gap: 9px; padding: 12px;
109
- background: var(--ar-surface); border: 0; border-radius: var(--ar-r-card); }
110
- .ar-group { display: flex; flex-direction: column; background: var(--ar-surface);
111
- border-radius: var(--ar-r-card); overflow: hidden; }
112
- .ar-item { display: flex; flex-direction: column; gap: 6px; padding: 11px 12px;
113
- transition: background-color .15s var(--ar-ease); }
114
- .ar-item + .ar-item { border-top: 1px solid var(--ar-sep); }
115
- .ar-head { display: flex; align-items: flex-start; gap: 8px; }
116
- .ar-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
117
- .ar-name { font-size: 13px; font-weight: 600; }
118
-
119
- /* ---- 徽章 / 方法标 ---- */
120
- .ar-badge { display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: var(--ar-r-pill);
121
- font-size: 11px; font-weight: 500; white-space: nowrap; line-height: 1;
122
- background: var(--ar-surface-strong); opacity: .9; }
123
- .ar-badge.ok { color: var(--ar-success); background: rgba(48, 209, 88, .14); opacity: 1; }
124
- .ar-badge.warn { color: var(--ar-warn); background: rgba(255, 214, 10, .14); opacity: 1; }
125
- .ar-badge.err { color: var(--ar-danger); background: rgba(255, 69, 58, .14); opacity: 1; }
126
- .ar-badge.brand { color: var(--ar-accent); background: rgba(10, 132, 255, .14); opacity: 1; }
127
- .ar-m { display: inline-flex; align-items: center; justify-content: center; flex: none;
128
- min-width: 46px; height: 19px; padding: 0 5px; border-radius: 5px;
129
- font-family: var(--ar-mono); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; }
130
- .ar-m.get { color: #0a84ff; background: rgba(10, 132, 255, .14); }
131
- .ar-m.post { color: #34c759; background: rgba(52, 199, 89, .16); }
132
- .ar-m.put, .ar-m.patch { color: #ff9f0a; background: rgba(255, 159, 10, .16); }
133
- .ar-m.del { color: #ff3b30; background: rgba(255, 59, 48, .14); }
134
-
135
- /* ---- 地址:host 弱、path 强 ---- */
136
- .ar-url { font-family: var(--ar-mono); font-size: 11.5px; overflow-wrap: anywhere; }
137
- .ar-url .h { opacity: .5; }
138
- .ar-url .p { opacity: .95; }
139
-
140
- /* ---- 按钮:胶囊填充 ---- */
141
- .ar-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px;
142
- border: 0; border-radius: var(--ar-r-ctrl); padding: 5px 12px; font: inherit; font-size: 12.5px; font-weight: 500;
143
- cursor: pointer; white-space: nowrap; color: inherit; background: var(--ar-surface);
144
- transition: background-color .18s var(--ar-ease), filter .18s var(--ar-ease), transform .18s var(--ar-ease), opacity .18s var(--ar-ease); }
145
- .ar-btn:active:not(:disabled) { transform: scale(.97); }
146
- .ar-btn:disabled { opacity: .38; cursor: default; transform: none; }
147
- .ar-btn.primary { background: var(--ar-accent); color: #fff; }
148
- .ar-btn.tonal { background: var(--ar-surface-strong); }
149
- .ar-btn.ghost { background: transparent; opacity: .62; padding: 5px 9px; }
150
- .ar-btn.danger { color: var(--ar-danger); }
151
- .ar-btn.icon { padding: 5px; width: 28px; }
152
- @media (hover: hover) and (pointer: fine) {
153
- .ar-btn:hover:not(:disabled) { background: var(--ar-surface-strong); }
154
- .ar-btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--ar-accent); }
155
- .ar-btn.ghost:hover:not(:disabled) { background: var(--ar-surface); opacity: .95; }
156
- .ar-btn.danger:hover:not(:disabled) { background: rgba(255, 69, 58, .12); }
157
- .ar-item:hover { background: var(--ar-surface-strong); }
158
- }
159
-
160
- /* ---- 表单:无边框填充 ---- */
161
- .ar-field { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 8px; align-items: center; }
162
- .ar-field.top { align-items: start; }
163
- .ar-field > label { font-size: 12px; opacity: .62; }
164
- .ar-field.top > label { padding-top: 7px; }
165
- .ar-in, .ar-sel, .ar-ta { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px;
166
- color: inherit; background: var(--ar-surface-strong); border: 0; border-radius: var(--ar-r-ctrl);
167
- padding: 6px 10px; outline: 2px solid transparent; outline-offset: -1px;
168
- transition: outline-color .18s var(--ar-ease); }
169
- .ar-in, .ar-sel { height: 30px; padding-top: 0; padding-bottom: 0; }
170
- .ar-sel { cursor: pointer; }
171
- .ar-ta { min-height: 60px; resize: vertical; font-family: var(--ar-mono); font-size: 12px; line-height: 1.5; }
172
- .ar-in:focus, .ar-sel:focus, .ar-ta:focus { outline-color: var(--ar-accent); }
173
- .ar-in::placeholder, .ar-ta::placeholder { color: inherit; opacity: .38; }
174
- .ar-check { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 13px; }
175
- .ar-check input { width: 15px; height: 15px; margin: 0; accent-color: var(--ar-accent); cursor: pointer; flex: none; }
176
- .ar-hint { font-size: 11.5px; opacity: .55; line-height: 1.55; }
177
-
178
- /* ---- 提示条 ---- */
179
- .ar-banner { display: flex; align-items: flex-start; gap: 7px; padding: 8px 11px;
180
- border-radius: var(--ar-r-ctrl); font-size: 12.5px; line-height: 1.5;
181
- background: var(--ar-surface); }
182
- .ar-banner > span { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
183
- /* 关闭按钮做成「纯文字 ×」:无边框无底色,颜色跟着提示条走,位置钉在最后一行右侧 */
184
- .ar-banner > .ar-x { flex: none; margin-left: auto; align-self: center;
185
- appearance: none; -webkit-appearance: none; border: 0 none; background: none; box-shadow: none;
186
- color: inherit; cursor: pointer; font-family: inherit; font-size: 15px; line-height: 1;
187
- padding: 0 1px 2px; border-radius: 4px; opacity: .55;
188
- transition: opacity .16s var(--ar-ease); }
189
- .ar-banner > .ar-x:hover { opacity: .95; }
190
- .ar-banner > .ar-x:focus-visible { outline: 1px solid currentColor; outline-offset: 1px; opacity: .95; }
191
- .ar-banner.err { color: var(--ar-danger); background: rgba(255, 69, 58, .12); }
192
- .ar-banner.ok { color: var(--ar-success); background: rgba(52, 199, 89, .12); }
193
-
194
- /* ---- 响应查看 ---- */
195
- .ar-pre { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl);
196
- background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 12px; line-height: 1.55;
197
- white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; }
198
- .ar-seg { display: flex; gap: 2px; padding: 2px; background: var(--ar-surface); border-radius: var(--ar-r-pill); }
199
- .ar-seg > button { border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11.5px;
200
- padding: 3px 9px; border-radius: 5px; opacity: .62; transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease); }
201
- .ar-seg > button:hover { opacity: .9; }
202
- .ar-seg > button.on { opacity: 1; font-weight: 600; background: var(--ar-surface-strong); }
203
-
204
- /* ---- 空态 ---- */
205
- .ar-empty { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 16px; text-align: center; }
206
- .ar-empty-icon { font-size: 22px; opacity: .3; line-height: 1; }
207
- .ar-empty-title { font-size: 13px; font-weight: 600; }
208
- .ar-empty-hint { font-size: 12px; opacity: .55; line-height: 1.6; max-width: 230px; }
209
-
210
- /* ---- 一键启动进度 ---- */
211
- .ar-batch { display: flex; flex-direction: column; gap: 6px; padding: 9px 11px; border-radius: var(--ar-r-card);
212
- background: var(--ar-surface); }
213
- .ar-batch-top { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
214
- .ar-batch-bar { height: 4px; border-radius: 2px; background: var(--ar-surface-strong); overflow: hidden; }
215
- .ar-batch-bar > i { display: block; height: 100%; border-radius: 2px; background: var(--ar-accent);
216
- transition: width .25s var(--ar-ease); }
217
- .ar-batch-line { display: flex; align-items: baseline; gap: 6px; font-size: 12px; }
218
- .ar-batch-line > .n { flex: 0 0 auto; font-family: var(--ar-mono); }
219
- .ar-batch-line.ok > .n { color: var(--ar-success); }
220
- .ar-batch-line.err > .n { color: var(--ar-danger); }
221
- .ar-batch-line.skip > .n { color: var(--ar-warn); }
222
-
223
- /* ---- 键值行 ---- */
224
- .ar-kv { display: flex; align-items: baseline; gap: 8px; font-size: 12px; }
225
- .ar-kv > .k { flex: 0 0 55px; opacity: .55; }
226
- .ar-kv > .v { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
227
-
228
- /* ---- 服务详情 / 实时日志 ---- */
229
- .ar-detail { display: flex; flex-direction: column; gap: 9px; min-width: 0; }
230
- .ar-detail-head { display: flex; align-items: center; gap: 7px; min-width: 0; }
231
- .ar-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--ar-success); }
232
- .ar-dot.off { background: var(--ar-danger); }
233
- /* 已忽略的服务整体压暗,但仍可操作(取消忽略) */
234
- .ar-ignored { opacity: .55; }
235
- /* 日志容器:宽度必须被父级约束住,否则 flex 的 min-width:auto 会让它按内容撑开,
236
- 看起来就像「不换行」(踩过:只设 white-space:pre-wrap 但父级没 min-width:0 时无效)。 */
237
- .ar-log { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl); width: 100%; box-sizing: border-box;
238
- background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 11.5px; line-height: 1.62;
239
- white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
240
- max-height: 58vh; min-height: 140px; overflow-y: auto; overflow-x: hidden; tab-size: 4; }
241
- .ar-log.nowrap { white-space: pre; overflow-wrap: normal; word-break: normal; overflow-x: auto; }
242
- .ar-log:empty::before { content: '(暂无日志输出)'; opacity: .45; }
243
-
244
- @media (prefers-reduced-motion: reduce) {
245
- .ar-root * { transition: none !important; animation: none !important; }
246
- }
56
+ /* ══════════════════════════════════════════════════════════════════════
57
+ dsh-api-runner 面板样式
58
+ 设计语言:与 DSH 宿主一致(中性灰阶 + 品牌色点缀 + 大圆角 + 柔和阴影)
59
+ 层级:顶部页签(容器内白底胶囊)→ 一级标题(独立小胶囊)→ 内容卡片
60
+ 所有颜色走 --dsw-alias-* token,取不到时回落到固定值,深浅色主题都成立
61
+ ══════════════════════════════════════════════════════════════════════ */
62
+ .ar-root {
63
+ display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; box-sizing: border-box;
64
+ font-size: 13px; line-height: 1.5; -webkit-font-smoothing: antialiased;
65
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
66
+
67
+ /* 中性面:从最浅到最深,用于分层 */
68
+ --ar-surface-1: rgba(120, 120, 128, .07);
69
+ --ar-surface-2: rgba(120, 120, 128, .12);
70
+ --ar-surface-3: rgba(120, 120, 128, .18);
71
+ --ar-elevated: var(--dsw-alias-bg-elevated, rgba(255, 255, 255, .16));
72
+ --ar-sep: rgba(120, 120, 128, .20);
73
+ --ar-sep-soft: rgba(120, 120, 128, .12);
74
+
75
+ /* 语义色 */
76
+ --ar-accent: var(--dsw-alias-brand-primary, #0a84ff);
77
+ --ar-accent-soft: rgba(10, 132, 255, .12);
78
+ --ar-danger: var(--dsw-alias-state-error-primary, #ff453a);
79
+ --ar-danger-soft: rgba(255, 69, 58, .12);
80
+ --ar-success: var(--dsw-alias-state-success-primary, #30d158);
81
+ --ar-success-soft: rgba(48, 209, 88, .12);
82
+ --ar-warn: var(--dsw-alias-state-warn-primary, #ffd60a);
83
+ --ar-warn-soft: rgba(255, 214, 10, .14);
84
+
85
+ /* 圆角与动效 */
86
+ --ar-r-lg: 14px;
87
+ --ar-r-md: 10px;
88
+ --ar-r-sm: 8px;
89
+ --ar-r-pill: 999px;
90
+ --ar-ease: cubic-bezier(.32, .72, 0, 1);
91
+ --ar-dur: .18s;
92
+
93
+ --ar-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Cascadia Mono', monospace;
94
+ }
95
+ @media (prefers-color-scheme: light) {
96
+ .ar-root {
97
+ --ar-surface-1: rgba(120, 120, 128, .05);
98
+ --ar-surface-2: rgba(120, 120, 128, .09);
99
+ --ar-surface-3: rgba(120, 120, 128, .14);
100
+ --ar-elevated: var(--dsw-alias-bg-elevated, #ffffff);
101
+ --ar-sep: rgba(60, 60, 67, .14);
102
+ --ar-sep-soft: rgba(60, 60, 67, .08);
103
+ --ar-accent-soft: rgba(10, 132, 255, .10);
104
+ --ar-danger: var(--dsw-alias-state-error-primary, #ff3b30);
105
+ --ar-danger-soft: rgba(255, 59, 48, .09);
106
+ --ar-success: var(--dsw-alias-state-success-primary, #34c759);
107
+ --ar-success-soft: rgba(52, 199, 89, .10);
108
+ --ar-warn: var(--dsw-alias-state-warn-primary, #ff9f0a);
109
+ --ar-warn-soft: rgba(255, 159, 10, .12);
110
+ }
111
+ }
112
+
113
+ /* ─────────────────────────── 顶部页签 ─────────────────────────── */
114
+ .ar-top { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 12px 14px 10px; }
115
+ .ar-tabs {
116
+ flex: 1 1 auto; display: flex; gap: 3px; padding: 3px; min-width: 0;
117
+ background: var(--ar-surface-2); border-radius: var(--ar-r-md);
118
+ overflow-x: auto; scrollbar-width: none;
119
+ }
120
+ .ar-tabs::-webkit-scrollbar { display: none; }
121
+ .ar-tab {
122
+ flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
123
+ border: 0; background: transparent; color: inherit; cursor: pointer;
124
+ padding: 6px 10px; font: inherit; font-size: 12.5px; font-weight: 500;
125
+ border-radius: var(--ar-r-sm); white-space: nowrap; opacity: .66;
126
+ transition: background-color var(--ar-dur) var(--ar-ease),
127
+ opacity var(--ar-dur) var(--ar-ease),
128
+ box-shadow var(--ar-dur) var(--ar-ease);
129
+ }
130
+ .ar-tab:hover { opacity: .9; }
131
+ .ar-tab.on {
132
+ opacity: 1; font-weight: 600; background: var(--ar-elevated);
133
+ box-shadow: 0 1px 2px rgba(0, 0, 0, .10), 0 2px 8px rgba(0, 0, 0, .06);
134
+ }
135
+ .ar-count {
136
+ font-size: 10px; font-weight: 600; line-height: 1; padding: 2px 5px;
137
+ border-radius: var(--ar-r-pill); background: var(--ar-surface-3); opacity: .8;
138
+ }
139
+ .ar-tab.on .ar-count { opacity: 1; }
140
+
141
+ /* ─────────────────────── 一级标题:独立胶囊 ───────────────────────
142
+ 面板很窄(右侧栏 ~300px),必须 nowrap + 横向滚动,否则会被挤成竖排。 */
143
+ .ar-segmented {
144
+ display: flex; align-items: center; gap: 6px; flex-wrap: nowrap;
145
+ padding: 0 0 12px; overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
146
+ }
147
+ .ar-segmented::-webkit-scrollbar { display: none; }
148
+ .ar-segmented.tight { padding-bottom: 0; }
149
+ .ar-seg {
150
+ flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px;
151
+ border: 0; cursor: pointer; padding: 6px 13px; font: inherit;
152
+ font-size: 12.5px; font-weight: 500; white-space: nowrap;
153
+ background: var(--ar-surface-2); color: inherit;
154
+ border-radius: var(--ar-r-pill); opacity: .78;
155
+ transition: background-color var(--ar-dur) var(--ar-ease),
156
+ opacity var(--ar-dur) var(--ar-ease),
157
+ box-shadow var(--ar-dur) var(--ar-ease);
158
+ }
159
+ .ar-seg:hover:not(:disabled) { opacity: 1; background: var(--ar-surface-3); }
160
+ .ar-seg:disabled { cursor: default; opacity: .45; }
161
+ .ar-seg.on {
162
+ opacity: 1; font-weight: 600; background: var(--ar-elevated);
163
+ box-shadow: 0 1px 2px rgba(0, 0, 0, .10), 0 2px 8px rgba(0, 0, 0, .06);
164
+ }
165
+ .ar-seg .n { font-size: 10.5px; font-weight: 600; opacity: .5; }
166
+ .ar-seg.on .n { opacity: .75; }
167
+
168
+ /* ─────────────────────────── 主体滚动区 ─────────────────────────── */
169
+ .ar-body {
170
+ flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto;
171
+ padding: 0 14px 28px; display: flex; flex-direction: column; gap: 12px;
172
+ scrollbar-width: thin;
173
+ }
174
+ .ar-pane { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
175
+
176
+ /* ─────────────────────────── 排版工具 ─────────────────────────── */
177
+ .ar-mono { font-family: var(--ar-mono); }
178
+ .ar-muted { opacity: .58; }
179
+ .ar-faint { opacity: .42; }
180
+ .ar-cut { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
181
+ .ar-spacer { flex: 1 1 auto; min-width: 0; }
182
+ .ar-row { display: flex; align-items: center; gap: 8px; }
183
+
184
+ /* 分组小标题:一行字 + 右延细线 */
185
+ .ar-sec { display: flex; align-items: center; gap: 10px; margin-top: 2px; }
186
+ .ar-sec .t {
187
+ font-size: 11.5px; font-weight: 600; letter-spacing: .03em;
188
+ text-transform: uppercase; opacity: .5; white-space: nowrap;
189
+ }
190
+ .ar-sec::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--ar-sep-soft); }
191
+
192
+ /* ─────────────────────── 二级页签(分组内) ───────────────────────
193
+ 与一级标题同款胶囊,但选中态用品牌色实心,区分层级。 */
194
+ .ar-tabs.sub { flex: none; background: transparent; padding: 0; gap: 6px; overflow: visible; }
195
+ .ar-tabs.sub .ar-tab {
196
+ flex: 0 0 auto; padding: 5px 12px; font-size: 12px;
197
+ background: var(--ar-surface-2); opacity: .78;
198
+ }
199
+ .ar-tabs.sub .ar-tab:hover { opacity: 1; background: var(--ar-surface-3); }
200
+ .ar-tabs.sub .ar-tab.on {
201
+ background: var(--ar-accent); color: #fff; opacity: 1; font-weight: 600;
202
+ box-shadow: 0 1px 4px rgba(10, 132, 255, .32);
203
+ }
204
+ .ar-tabs.sub .ar-count { background: rgba(255, 255, 255, .22); opacity: .95; }
205
+ .ar-tabs.sub .ar-tab:not(.on) .ar-count { background: var(--ar-surface-3); }
206
+
207
+ /* 项目分类页签:多了就横向滚动 */
208
+ .ar-tabs.ar-projects { overflow-x: auto; scrollbar-width: none; }
209
+ .ar-tabs.ar-projects::-webkit-scrollbar { display: none; }
210
+ .ar-tabs.ar-projects .ar-tab { flex: 0 0 auto; max-width: 140px; }
211
+ .ar-tabs.ar-projects .ar-tab .l { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
212
+
213
+ /* ─────────────────────────── 卡片与列表 ─────────────────────────── */
214
+ .ar-card {
215
+ display: flex; flex-direction: column; gap: 10px; padding: 14px;
216
+ background: var(--ar-surface-1); border-radius: var(--ar-r-lg);
217
+ border: 1px solid var(--ar-sep-soft);
218
+ }
219
+ .ar-group {
220
+ display: flex; flex-direction: column;
221
+ background: var(--ar-surface-1); border-radius: var(--ar-r-lg);
222
+ border: 1px solid var(--ar-sep-soft); overflow: hidden;
223
+ }
224
+ .ar-item {
225
+ display: flex; flex-direction: column; gap: 7px; padding: 12px 14px;
226
+ transition: background-color .15s var(--ar-ease);
227
+ }
228
+ .ar-item + .ar-item { border-top: 1px solid var(--ar-sep-soft); }
229
+ .ar-item-on { box-shadow: inset 3px 0 0 var(--ar-accent); background: var(--ar-accent-soft); }
230
+ .ar-head { display: flex; align-items: flex-start; gap: 10px; }
231
+ .ar-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
232
+ .ar-name { font-size: 13.5px; font-weight: 600; letter-spacing: -.005em; }
233
+
234
+ /* ─────────────────────────── 徽章 / 方法标 ─────────────────────────── */
235
+ .ar-badge {
236
+ display: inline-flex; align-items: center; height: 19px; padding: 0 7px;
237
+ border-radius: var(--ar-r-pill); font-size: 11px; font-weight: 500;
238
+ white-space: nowrap; line-height: 1; background: var(--ar-surface-3); opacity: .92;
239
+ }
240
+ .ar-badge.ok { color: var(--ar-success); background: var(--ar-success-soft); opacity: 1; }
241
+ .ar-badge.warn { color: var(--ar-warn); background: var(--ar-warn-soft); opacity: 1; }
242
+ .ar-badge.err { color: var(--ar-danger); background: var(--ar-danger-soft); opacity: 1; }
243
+ .ar-badge.brand { color: var(--ar-accent); background: var(--ar-accent-soft); opacity: 1; }
244
+
245
+ .ar-m {
246
+ display: inline-flex; align-items: center; justify-content: center; flex: none;
247
+ min-width: 48px; height: 20px; padding: 0 6px; border-radius: 6px;
248
+ font-family: var(--ar-mono); font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
249
+ }
250
+ .ar-m.get { color: #0a84ff; background: rgba(10, 132, 255, .14); }
251
+ .ar-m.post { color: #34c759; background: rgba(52, 199, 89, .15); }
252
+ .ar-m.put, .ar-m.patch { color: #ff9f0a; background: rgba(255, 159, 10, .15); }
253
+ .ar-m.del { color: #ff3b30; background: rgba(255, 59, 48, .13); }
254
+
255
+ /* ─────────────────────────── 地址行 ─────────────────────────── */
256
+ .ar-url { font-family: var(--ar-mono); font-size: 11.5px; overflow-wrap: anywhere; line-height: 1.5; }
257
+ .ar-url .h { opacity: .45; }
258
+ .ar-url .p { opacity: .95; }
259
+
260
+ /* ─────────────────────────── 按钮 ─────────────────────────── */
261
+ .ar-btn {
262
+ display: inline-flex; align-items: center; justify-content: center; gap: 5px;
263
+ border: 0; border-radius: var(--ar-r-sm); padding: 6px 13px;
264
+ font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer;
265
+ white-space: nowrap; color: inherit; background: var(--ar-surface-2);
266
+ transition: background-color var(--ar-dur) var(--ar-ease),
267
+ filter var(--ar-dur) var(--ar-ease),
268
+ transform .1s var(--ar-ease),
269
+ opacity var(--ar-dur) var(--ar-ease);
270
+ }
271
+ .ar-btn:active:not(:disabled) { transform: scale(.97); }
272
+ .ar-btn:disabled { opacity: .35; cursor: default; transform: none; }
273
+ .ar-btn.primary {
274
+ background: var(--ar-accent); color: #fff; font-weight: 600;
275
+ box-shadow: 0 1px 3px rgba(10, 132, 255, .28);
276
+ }
277
+ .ar-btn.tonal { background: var(--ar-surface-3); }
278
+ .ar-btn.ghost { background: transparent; opacity: .6; padding: 6px 10px; }
279
+ .ar-btn.danger { color: var(--ar-danger); }
280
+ .ar-btn.icon { padding: 6px; width: 30px; }
281
+ @media (hover: hover) and (pointer: fine) {
282
+ .ar-btn:hover:not(:disabled) { background: var(--ar-surface-3); }
283
+ .ar-btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--ar-accent); }
284
+ .ar-btn.ghost:hover:not(:disabled) { background: var(--ar-surface-2); opacity: .95; }
285
+ .ar-btn.danger:hover:not(:disabled) { background: var(--ar-danger-soft); }
286
+ .ar-item:hover { background: var(--ar-surface-2); }
287
+ }
288
+
289
+ /* ─────────────────────────── 表单 ─────────────────────────── */
290
+ .ar-field { display: grid; grid-template-columns: 62px minmax(0, 1fr); gap: 10px; align-items: center; }
291
+ .ar-field.top { align-items: start; }
292
+ .ar-field > label { font-size: 12px; opacity: .58; letter-spacing: .01em; }
293
+ .ar-field.top > label { padding-top: 8px; }
294
+ .ar-in, .ar-sel, .ar-ta {
295
+ width: 100%; box-sizing: border-box; font: inherit; font-size: 13px; color: inherit;
296
+ background: var(--ar-surface-2); border: 0; border-radius: var(--ar-r-sm);
297
+ padding: 7px 11px; outline: 2px solid transparent; outline-offset: -1px;
298
+ transition: outline-color var(--ar-dur) var(--ar-ease), background-color var(--ar-dur) var(--ar-ease);
299
+ }
300
+ .ar-in, .ar-sel { height: 32px; padding-top: 0; padding-bottom: 0; }
301
+ .ar-sel { cursor: pointer; }
302
+ .ar-ta {
303
+ min-height: 64px; resize: vertical;
304
+ font-family: var(--ar-mono); font-size: 12px; line-height: 1.6;
305
+ }
306
+ .ar-in:focus, .ar-sel:focus, .ar-ta:focus {
307
+ outline-color: var(--ar-accent); background: var(--ar-surface-1);
308
+ }
309
+ .ar-in::placeholder, .ar-ta::placeholder { color: inherit; opacity: .34; }
310
+ .ar-check { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12.5px; }
311
+ .ar-check input {
312
+ width: 15px; height: 15px; margin: 0; flex: none;
313
+ accent-color: var(--ar-accent); cursor: pointer;
314
+ }
315
+ .ar-hint { font-size: 11.5px; opacity: .52; line-height: 1.6; }
316
+
317
+ /* ─────────────────────────── 提示条 ─────────────────────────── */
318
+ .ar-banner {
319
+ display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px;
320
+ border-radius: var(--ar-r-md); font-size: 12.5px; line-height: 1.5;
321
+ background: var(--ar-surface-2); border-left: 3px solid transparent;
322
+ }
323
+ .ar-banner > span { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
324
+ .ar-banner > .ar-x {
325
+ flex: none; margin-left: auto; align-self: center;
326
+ appearance: none; -webkit-appearance: none; border: 0 none; background: none; box-shadow: none;
327
+ color: inherit; cursor: pointer; font-family: inherit; font-size: 15px; line-height: 1;
328
+ padding: 0 1px 2px; border-radius: 4px; opacity: .5;
329
+ transition: opacity .16s var(--ar-ease);
330
+ }
331
+ .ar-banner > .ar-x:hover { opacity: .95; }
332
+ .ar-banner > .ar-x:focus-visible { outline: 1px solid currentColor; outline-offset: 1px; opacity: .95; }
333
+ .ar-banner.err { color: var(--ar-danger); background: var(--ar-danger-soft); border-left-color: var(--ar-danger); }
334
+ .ar-banner.ok { color: var(--ar-success); background: var(--ar-success-soft); border-left-color: var(--ar-success); }
335
+
336
+ /* ─────────────────────────── 响应查看 ─────────────────────────── */
337
+ .ar-pre {
338
+ margin: 0; padding: 12px; border-radius: var(--ar-r-md);
339
+ background: var(--ar-surface-2); font-family: var(--ar-mono); font-size: 12px; line-height: 1.6;
340
+ white-space: pre-wrap; overflow-wrap: anywhere; max-height: 340px; overflow: auto;
341
+ tab-size: 2;
342
+ }
343
+
344
+ /* 响应查看的切换条(注意:不叫 ar-seg,避免和一级标题胶囊冲突) */
345
+ .ar-switch {
346
+ display: flex; gap: 2px; padding: 2px;
347
+ background: var(--ar-surface-2); border-radius: var(--ar-r-sm);
348
+ }
349
+ .ar-switch > button {
350
+ border: 0; background: transparent; color: inherit; cursor: pointer;
351
+ font: inherit; font-size: 11.5px; font-weight: 500;
352
+ padding: 4px 10px; border-radius: 6px; opacity: .58;
353
+ transition: background-color var(--ar-dur) var(--ar-ease), opacity var(--ar-dur) var(--ar-ease);
354
+ }
355
+ .ar-switch > button:hover { opacity: .88; }
356
+ .ar-switch > button.on {
357
+ opacity: 1; font-weight: 600; background: var(--ar-elevated);
358
+ box-shadow: 0 1px 2px rgba(0, 0, 0, .08);
359
+ }
360
+
361
+ /* ─────────────────────────── 空态 ─────────────────────────── */
362
+ .ar-empty {
363
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
364
+ padding: 36px 18px; text-align: center;
365
+ }
366
+ .ar-empty-icon {
367
+ font-size: 26px; opacity: .26; line-height: 1;
368
+ width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
369
+ border-radius: 50%; background: var(--ar-surface-1);
370
+ }
371
+ .ar-empty-title { font-size: 13.5px; font-weight: 600; }
372
+ .ar-empty-hint { font-size: 12px; opacity: .52; line-height: 1.65; max-width: 250px; }
373
+
374
+ /* ─────────────────────────── 表格 ─────────────────────────── */
375
+ .ar-table-wrap {
376
+ width: 100%; overflow-x: auto; border-radius: var(--ar-r-md);
377
+ background: var(--ar-surface-1); border: 1px solid var(--ar-sep-soft);
378
+ }
379
+ .ar-table { border-collapse: collapse; width: 100%; font-size: 12px; font-family: var(--ar-mono); }
380
+ .ar-table th {
381
+ text-align: left; font-weight: 600; padding: 8px 11px; white-space: nowrap;
382
+ border-bottom: 1px solid var(--ar-sep); opacity: .62;
383
+ position: sticky; top: 0; background: var(--ar-surface-2);
384
+ }
385
+ .ar-table td {
386
+ padding: 7px 11px; border-bottom: 1px solid var(--ar-sep-soft);
387
+ white-space: nowrap; max-width: 340px; overflow: hidden; text-overflow: ellipsis;
388
+ }
389
+ .ar-table tbody tr:last-child td { border-bottom: none; }
390
+ .ar-table tbody tr:hover td { background: var(--ar-surface-2); }
391
+
392
+ /* ─────────────────────────── 进度 ─────────────────────────── */
393
+ .ar-batch {
394
+ display: flex; flex-direction: column; gap: 8px; padding: 12px 14px;
395
+ border-radius: var(--ar-r-lg); background: var(--ar-surface-1);
396
+ border: 1px solid var(--ar-sep-soft);
397
+ }
398
+ .ar-batch-top { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; }
399
+ .ar-batch-bar { height: 5px; border-radius: var(--ar-r-pill); background: var(--ar-surface-3); overflow: hidden; }
400
+ .ar-batch-bar > i {
401
+ display: block; height: 100%; border-radius: var(--ar-r-pill);
402
+ background: var(--ar-accent); transition: width .3s var(--ar-ease);
403
+ }
404
+ .ar-batch-line { display: flex; align-items: baseline; gap: 7px; font-size: 12px; }
405
+ .ar-batch-line > .n { flex: 0 0 auto; font-family: var(--ar-mono); font-weight: 700; }
406
+ .ar-batch-line.ok > .n { color: var(--ar-success); }
407
+ .ar-batch-line.err > .n { color: var(--ar-danger); }
408
+ .ar-batch-line.skip > .n { color: var(--ar-warn); }
409
+
410
+ /* ─────────────────────────── 键值行 ─────────────────────────── */
411
+ .ar-kv { display: flex; align-items: baseline; gap: 10px; font-size: 12px; }
412
+ .ar-kv > .k { flex: 0 0 58px; opacity: .5; }
413
+ .ar-kv > .v { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
414
+
415
+ /* ─────────────────────────── 服务详情 / 日志 ─────────────────────────── */
416
+ .ar-detail { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
417
+ .ar-detail-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
418
+ .ar-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--ar-success); }
419
+ .ar-dot.off { background: var(--ar-danger); }
420
+ .ar-ignored { opacity: .5; }
421
+ /* 日志容器:父级必须有 min-width:0,否则 flex 会按内容撑开导致「不换行」 */
422
+ .ar-log {
423
+ margin: 0; padding: 12px; border-radius: var(--ar-r-md); width: 100%; box-sizing: border-box;
424
+ background: var(--ar-surface-2); font-family: var(--ar-mono); font-size: 11.5px; line-height: 1.65;
425
+ white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
426
+ max-height: 58vh; min-height: 150px; overflow-y: auto; overflow-x: hidden; tab-size: 4;
427
+ }
428
+ .ar-log.nowrap { white-space: pre; overflow-wrap: normal; word-break: normal; overflow-x: auto; }
429
+ .ar-log:empty::before { content: '(暂无日志输出)'; opacity: .42; }
430
+
431
+ @media (prefers-reduced-motion: reduce) {
432
+ .ar-root * { transition: none !important; animation: none !important; }
433
+ }
434
+
247
435
  `;
248
436
 
249
437
  /**
250
438
  * 插入样式表。
251
- * 优先用宿主给动态 Client half 的 styles.insert(由 DSH 管理生命周期、
252
- * 保证插到正确的文档根);取不到才退回 document.head,与 dsh-db-tool 同法。
439
+ *
440
+ * 不再依赖宿主的 styles.insert:它按插件 id 去重,插件升级后页面里
441
+ * 仍是旧 CSS(胶囊退化成原生 button、竖排),刷新也无效。
442
+ * 现在自己维护 <style>,并**先移除所有旧版本元素**再插入,
443
+ * 保证每次加载都是当前这份样式。
253
444
  */
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;
445
+ function installStyles() {
446
+ const head = document.head || document.documentElement;
447
+ // 清掉历史版本(含旧的无版本号 id),避免旧规则覆盖新规则
448
+ for (const node of document.querySelectorAll('style[id^="dsh-api-runner-style"]')) {
449
+ if (node.id !== STYLE_ID) node.remove();
450
+ }
451
+ let el = document.getElementById(STYLE_ID);
452
+ if (el === null) {
453
+ el = document.createElement('style');
454
+ el.id = STYLE_ID;
455
+ head.appendChild(el);
261
456
  }
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);
457
+ if (el.textContent !== CSS) el.textContent = CSS;
267
458
  }
268
459
 
269
460
  // ---------------------------------------------------------------- 工具
@@ -495,7 +686,7 @@ window.__ModuleLoader__.load({
495
686
  h('input', { type: 'checkbox', checked: value.isLogin, onChange: (e) => set('isLogin', e.target.checked) }),
496
687
  h('span', {}, '这是登录接口,调用成功后保存 token')),
497
688
  value.isLogin
498
- ? h('div', { className: 'ar-card', style: { background: 'var(--ar-surface-strong)' } },
689
+ ? h('div', { className: 'ar-card', style: { background: 'var(--ar-surface-2)' } },
499
690
  h(Field, { label: 'token 路径' }, h('input', { className: 'ar-in', value: value.tokenPath, placeholder: '响应 JSON 路径,如 data.token', onChange: (e) => set('tokenPath', e.target.value) })),
500
691
  h(Field, { label: '注入头' }, h('input', { className: 'ar-in', value: value.authHeader, placeholder: 'Authorization', onChange: (e) => set('authHeader', e.target.value) })),
501
692
  h(Field, { label: '前缀' }, h('input', { className: 'ar-in', value: value.prefix, placeholder: 'Bearer (不需要就留空)', onChange: (e) => set('prefix', e.target.value) })),
@@ -611,6 +802,256 @@ window.__ModuleLoader__.load({
611
802
  : null);
612
803
  }
613
804
 
805
+ /**
806
+ * 数据库连接编辑表单。
807
+ *
808
+ * 三种录入方式:
809
+ * - sqlite:只填文件路径
810
+ * - 分字段:host / 端口 / 库名 / 用户名 / 密码
811
+ * - URL:整条连接串(dm://user:pass@host:port?opts)
812
+ *
813
+ * 端口输入框单独占一行、不固定像素宽——之前和 host 挤在一行、
814
+ * 宽度写死 90px,在窄面板里被压变形(用户报的「端口位置有 bug」)。
815
+ *
816
+ * 密码框 type=password 且编辑时不回填:服务端只回传掩码,
817
+ * 留空表示「不修改密码」,避免编辑别处字段时把密码清掉。
818
+ */
819
+ function EditDbForm(props) {
820
+ const { value, onChange, onSave, onTest, onCancel, busy, testing, testResult } = props;
821
+ const set = (key, next) => onChange({ ...value, [key]: next });
822
+ const isSqlite = value.type === 'sqlite';
823
+ const urlMode = value.urlMode === true;
824
+
825
+ /** 把表单值整理成后端要的形状(保存与试连共用) */
826
+ const toPayload = () => {
827
+ let extra;
828
+ const text = String(value.extraText || '').trim();
829
+ if (text !== '') {
830
+ try {
831
+ extra = JSON.parse(text);
832
+ } catch (error) {
833
+ window.alert(`额外参数不是合法 JSON:${error.message}`);
834
+ return undefined;
835
+ }
836
+ }
837
+ const base = {
838
+ id: value.id || undefined,
839
+ name: value.name,
840
+ type: value.type,
841
+ username: value.username,
842
+ // 空密码不传,让后端保留原值
843
+ password: value.password === '' ? undefined : value.password,
844
+ extra,
845
+ };
846
+ if (isSqlite) return { ...base, database: value.database };
847
+ if (urlMode) return { ...base, connectString: value.connectString };
848
+ return {
849
+ ...base,
850
+ host: value.host,
851
+ port: value.port === '' ? undefined : Number(value.port),
852
+ database: value.database,
853
+ };
854
+ };
855
+
856
+ const runTest = () => {
857
+ const payload = toPayload();
858
+ if (payload === undefined) return;
859
+ if (onTest) onTest(payload);
860
+ };
861
+
862
+ return h('div', { className: 'ar-card' },
863
+ h('div', { className: 'ar-name' }, value.id ? `编辑连接 ${value.name}` : '新增数据库连接'),
864
+
865
+ // 名称 + 类型同一行(两个都不会太窄)
866
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '8px' } },
867
+ h('div', { style: { flex: '1 1 140px', minWidth: '120px' } },
868
+ h(Field, { label: '名称' }, h('input', {
869
+ className: 'ar-in', value: value.name, placeholder: '如 本地 MySQL',
870
+ onChange: (e) => set('name', e.target.value),
871
+ }))),
872
+ h('div', { style: { flex: '0 1 130px', minWidth: '100px' } },
873
+ h(Field, { label: '类型' }, h('select', {
874
+ className: 'ar-sel', value: value.type,
875
+ onChange: (e) => set('type', e.target.value),
876
+ }, DB_TYPE_OPTIONS.map((t) => h('option', { key: t.value, value: t.value }, t.label)))))),
877
+
878
+ // sqlite:只有文件路径
879
+ isSqlite
880
+ ? h(Field, { label: '文件路径' }, h('input', {
881
+ className: 'ar-in ar-mono', value: value.database,
882
+ placeholder: ':memory: 或 D:\\data\\app.db',
883
+ onChange: (e) => set('database', e.target.value),
884
+ }))
885
+ : h('div', null,
886
+ // 录入方式切换(URL / 分字段)
887
+ h(Field, { label: '录入方式' }, h('div', { className: 'ar-segmented', style: { padding: '0' } },
888
+ [{ id: false, label: '分字段' }, { id: true, label: 'URL' }].map((opt) => h('button', {
889
+ key: String(opt.id),
890
+ className: urlMode === opt.id ? 'ar-seg on' : 'ar-seg',
891
+ onClick: () => set('urlMode', opt.id),
892
+ }, opt.label)))),
893
+
894
+ urlMode
895
+ ? h('div', null,
896
+ h(Field, { label: '连接 URL', top: true }, h('textarea', {
897
+ className: 'ar-ta ar-mono', value: value.connectString,
898
+ placeholder: value.type === 'dm'
899
+ ? 'dm://user:password@host:5236'
900
+ : `${value.type}://user:password@host:port/database`,
901
+ style: { minHeight: '52px' },
902
+ onChange: (e) => set('connectString', e.target.value),
903
+ })),
904
+ h('div', { className: 'ar-hint' }, '整条串会加密保存,界面只显示打码后的样子。'))
905
+ : h('div', null,
906
+ // host 单独一行,不再和端口挤
907
+ h(Field, { label: 'host' }, h('input', {
908
+ className: 'ar-in ar-mono', value: value.host, placeholder: '127.0.0.1',
909
+ onChange: (e) => set('host', e.target.value),
910
+ })),
911
+ // 端口与库名同一行,但都允许换行收缩
912
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '8px' } },
913
+ h('div', { style: { flex: '0 1 120px', minWidth: '90px' } },
914
+ h(Field, { label: '端口' }, h('input', {
915
+ className: 'ar-in ar-mono', value: value.port,
916
+ placeholder: String(DEFAULT_PORT_HINT[value.type] || '默认'),
917
+ inputMode: 'numeric',
918
+ onChange: (e) => set('port', e.target.value.replace(/[^\d]/g, '')),
919
+ }))),
920
+ h('div', { style: { flex: '1 1 150px', minWidth: '110px' } },
921
+ h(Field, { label: '数据库' }, h('input', {
922
+ className: 'ar-in ar-mono', value: value.database, placeholder: '库名(可选)',
923
+ onChange: (e) => set('database', e.target.value),
924
+ })))),
925
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '8px' } },
926
+ h('div', { style: { flex: '1 1 140px', minWidth: '110px' } },
927
+ h(Field, { label: '用户名' }, h('input', {
928
+ className: 'ar-in', value: value.username, placeholder: 'root',
929
+ onChange: (e) => set('username', e.target.value),
930
+ }))),
931
+ h('div', { style: { flex: '1 1 140px', minWidth: '110px' } },
932
+ h(Field, { label: '密码' }, h('input', {
933
+ className: 'ar-in', type: 'password', value: value.password,
934
+ placeholder: value.id ? '留空表示不修改' : '',
935
+ onChange: (e) => set('password', e.target.value),
936
+ })))))),
937
+
938
+ h(Field, { label: '额外参数', top: true }, h('textarea', {
939
+ className: 'ar-ta ar-mono', value: value.extraText,
940
+ placeholder: '可选,JSON:{"ssl":true}',
941
+ onChange: (e) => set('extraText', e.target.value),
942
+ })),
943
+
944
+ // 试连结果就地显示,不用保存也能验证
945
+ testResult
946
+ ? h('div', {
947
+ className: 'ar-hint',
948
+ style: { color: testResult.ok ? 'var(--ar-success)' : 'var(--ar-danger)' },
949
+ }, testResult.ok
950
+ ? `✓ 连接成功${testResult.durationMs ? `(${testResult.durationMs}ms)` : ''}`
951
+ : `✗ ${testResult.message || '连接失败'}`)
952
+ : null,
953
+
954
+ h('div', { className: 'ar-hint' }, '密码会加密后保存,列表与工具输出只显示掩码。'),
955
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
956
+ h('button', {
957
+ className: 'ar-btn primary',
958
+ // 保存只受「保存中」影响;试连进行中仍可保存
959
+ disabled: busy === 'save',
960
+ onClick: () => {
961
+ const payload = toPayload();
962
+ if (payload === undefined) return;
963
+ onSave(payload);
964
+ },
965
+ }, busy === 'save' ? '保存中…' : '保存'),
966
+ // 新增时也能试连:把当前表单值发给后端,不落盘。
967
+ // 只有「测试」按钮自己 disabled,不影响保存/取消。
968
+ h('button', {
969
+ className: 'ar-btn tonal',
970
+ disabled: testing || busy === 'save',
971
+ onClick: runTest,
972
+ title: '用当前填写的内容试连,不需要先保存',
973
+ }, testing ? '测试中…' : '测试'),
974
+ h('span', { className: 'ar-spacer' }),
975
+ h('button', { className: 'ar-btn ghost', onClick: onCancel }, '取消')));
976
+ }
977
+
978
+ /** SQL 执行结果:表格 + 行数/耗时 */
979
+ function DbResultCard(props) {
980
+ const { result, onClose } = props;
981
+ if (!result) return null;
982
+ if (result.ok === false) {
983
+ return h('div', { className: 'ar-card' },
984
+ h('div', { className: 'ar-row' },
985
+ h('span', { className: 'ar-badge', style: { background: 'var(--ar-danger)', color: '#fff' } }, result.code || 'ERROR'),
986
+ h('span', { className: 'ar-spacer' }),
987
+ h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
988
+ h('div', { className: 'ar-hint ar-mono', style: { overflowWrap: 'anywhere' } }, result.message || '执行失败'));
989
+ }
990
+ const columns = Array.isArray(result.columns) ? result.columns : [];
991
+ const rows = Array.isArray(result.rows) ? result.rows : [];
992
+ const affected = result.affectedRows;
993
+ return h('div', { className: 'ar-card' },
994
+ h('div', { className: 'ar-row' },
995
+ h('span', { className: 'ar-badge ok' }, 'OK'),
996
+ h('span', { className: 'ar-hint' }, `${result.rowCount} 行 · ${result.durationMs}ms`),
997
+ result.truncated ? h('span', { className: 'ar-badge', style: { background: 'var(--ar-warn)' } }, `已截断至 ${result.maxRows}`) : null,
998
+ h('span', { className: 'ar-spacer' }),
999
+ h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
1000
+ typeof affected === 'number' && affected > 0
1001
+ ? h('div', { className: 'ar-hint' }, `影响 ${affected} 行`)
1002
+ : null,
1003
+ columns.length > 0 && rows.length > 0
1004
+ ? h('div', { className: 'ar-table-wrap' },
1005
+ h('table', { className: 'ar-table' },
1006
+ h('thead', null, h('tr', null, columns.map((c) => h('th', { key: c }, c)))),
1007
+ h('tbody', null, rows.map((row, index) => h('tr', { key: index },
1008
+ columns.map((c) => h('td', { key: c }, formatCell(row[c]))))))))
1009
+ : h('div', { className: 'ar-hint' }, rows.length === 0 ? '查询成功,没有数据行。' : ''));
1010
+ }
1011
+
1012
+ /** 单元格渲染:对象/数组转 JSON,null 显示为灰色占位 */
1013
+ function formatCell(value) {
1014
+ if (value === null) return h('span', { className: 'ar-faint' }, 'NULL');
1015
+ if (value === undefined) return '';
1016
+ if (typeof value === 'object') {
1017
+ try {
1018
+ return JSON.stringify(value);
1019
+ } catch {
1020
+ return String(value);
1021
+ }
1022
+ }
1023
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
1024
+ return String(value);
1025
+ }
1026
+
1027
+ /** 表结构:字段表 + 索引 */
1028
+ function TableDetail(props) {
1029
+ const { detail, onClose } = props;
1030
+ const columns = Array.isArray(detail.columns) ? detail.columns : [];
1031
+ const indexes = Array.isArray(detail.indexes) ? detail.indexes : [];
1032
+ return h('div', { className: 'ar-card' },
1033
+ h('div', { className: 'ar-row' },
1034
+ h('span', { className: 'ar-name ar-mono' }, detail.table),
1035
+ h('span', { className: 'ar-spacer' }),
1036
+ h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
1037
+ h('div', { className: 'ar-table-wrap' },
1038
+ h('table', { className: 'ar-table' },
1039
+ h('thead', null, h('tr', null,
1040
+ h('th', null, '字段'), h('th', null, '类型'), h('th', null, '可空'), h('th', null, '默认'), h('th', null, '主键'))),
1041
+ h('tbody', null, columns.map((c) => h('tr', { key: c.name },
1042
+ h('td', { className: 'ar-mono' }, c.name),
1043
+ h('td', { className: 'ar-mono ar-muted' }, c.type || ''),
1044
+ h('td', null, c.nullable ? 'Y' : ''),
1045
+ h('td', { className: 'ar-mono ar-muted' }, c.defaultValue === null || c.defaultValue === undefined ? '' : String(c.defaultValue)),
1046
+ h('td', null, c.primaryKey ? 'PK' : '')))))),
1047
+ indexes.length > 0
1048
+ ? h('div', null,
1049
+ h('div', { className: 'ar-sec' }, h('span', { className: 't' }, `索引 · ${indexes.length}`)),
1050
+ indexes.map((i) => h('div', { key: i.name, className: 'ar-hint ar-mono ar-cut' },
1051
+ `${i.name}${i.unique ? ' (unique)' : ''}${Array.isArray(i.columns) ? ` · ${i.columns.join(', ')}` : ''}${i.definition ? ` · ${i.definition}` : ''}`)))
1052
+ : null);
1053
+ }
1054
+
614
1055
  function SavedRow(props) {
615
1056
  const { item, busy, onStart, onDelete } = props;
616
1057
  return h('div', { className: 'ar-item' },
@@ -845,7 +1286,7 @@ window.__ModuleLoader__.load({
845
1286
  typeof payload.durationMs === 'number' ? h('span', { className: 'ar-hint ar-mono' }, `${payload.durationMs} ms`) : null,
846
1287
  h('span', { className: 'ar-hint ar-mono' }, byteSize(json !== undefined ? json : payload.body)),
847
1288
  h('span', { className: 'ar-spacer' }),
848
- h('div', { className: 'ar-seg' }, views.map((v) => h('button', { key: v.id, className: view === v.id ? 'on' : '', onClick: () => setView(v.id) }, v.label))),
1289
+ h('div', { className: 'ar-switch' }, views.map((v) => h('button', { key: v.id, className: view === v.id ? 'on' : '', onClick: () => setView(v.id) }, v.label))),
849
1290
  h('button', { className: 'ar-btn ghost icon', title: '复制这段内容', onClick: () => copyText(content) }, '⧉'),
850
1291
  h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
851
1292
  h('div', { className: 'ar-row', style: { minWidth: 0 } },
@@ -888,6 +1329,25 @@ window.__ModuleLoader__.load({
888
1329
  const [mavenHomeDraft, setMavenHomeDraft] = React.useState('');
889
1330
  /** 一键启动的后台批次进度(null = 没有正在进行的批次) */
890
1331
  const [batch, setBatch] = React.useState(null);
1332
+ /** 数据库:连接列表 / 选中的连接 / 执行结果 / 表清单 */
1333
+ const [dbConnections, setDbConnections] = React.useState([]);
1334
+ const [dbError, setDbError] = React.useState('');
1335
+ const [dbActive, setDbActive] = React.useState('');
1336
+ const [dbForm, setDbForm] = React.useState(null);
1337
+ const [dbSql, setDbSql] = React.useState('');
1338
+ const [dbResult, setDbResult] = React.useState(null);
1339
+ const [dbReadonly, setDbReadonly] = React.useState(true);
1340
+ const [dbTables, setDbTables] = React.useState([]);
1341
+ const [dbTableDetail, setDbTableDetail] = React.useState(null);
1342
+ const [dbDbBusy, setDbDbBusy] = React.useState('');
1343
+ /** 数据库子页的一级标题:conn(连接)/ sql / schema(表结构) */
1344
+ const [dbSection, setDbSection] = React.useState('conn');
1345
+ /** 表单试连结果(null = 没试过) */
1346
+ const [dbTestResult, setDbTestResult] = React.useState(null);
1347
+ /** 项目授权视图:{ projectKey, connections: [{id,name,type,target,level}], authorized, total } */
1348
+ const [dbAccess, setDbAccess] = React.useState(null);
1349
+ /** 表单试连进行中(独立状态,不锁其它按钮) */
1350
+ const [dbTesting, setDbTesting] = React.useState(false);
891
1351
 
892
1352
  const headers = React.useMemo(() => ({ 'Content-Type': 'application/json', 'x-dsh-session-id': sessionId }), [sessionId]);
893
1353
  const get = React.useCallback(async (path) => {
@@ -1318,9 +1778,198 @@ window.__ModuleLoader__.load({
1318
1778
  }
1319
1779
  };
1320
1780
 
1781
+ // ---------------------------------------------------------------- 数据库
1782
+ /**
1783
+ * 数据库连接是跨项目的(同一套库常被多个项目共用),
1784
+ * 所以这里不跟着「接口」页的项目页签走,始终操作全局连接列表。
1785
+ */
1786
+ /**
1787
+ * 项目授权视图:哪些连接对「当前项目」开放、各是什么等级。
1788
+ * 这是模型能否查到数据的总开关——未授权的连接连 describe 都不会出现。
1789
+ */
1790
+ const refreshDbAccess = React.useCallback(async () => {
1791
+ try {
1792
+ const data = await request('db/access', { headers });
1793
+ if (data.ok === false) return;
1794
+ setDbAccess(data);
1795
+ } catch {
1796
+ /* 授权视图加载失败不阻塞主流程 */
1797
+ }
1798
+ }, [headers]);
1799
+
1800
+ const setDbAccessLevel = async (connectionId, level) => {
1801
+ setDbDbBusy('access');
1802
+ setDbError('');
1803
+ try {
1804
+ const payload = await post('db/access', { connection_id: connectionId, level });
1805
+ if (payload.ok === false) throw new Error(payload.message || '设置授权失败');
1806
+ setNotice(`已设为「${level === 'none' ? '未授权' : level === 'readonly' ? '只读' : '读写'}」`);
1807
+ await refreshDbAccess();
1808
+ } catch (e) {
1809
+ setDbError(e.message);
1810
+ } finally {
1811
+ setDbDbBusy('');
1812
+ }
1813
+ };
1814
+
1815
+ const refreshDb = React.useCallback(async () => {
1816
+ try {
1817
+ const data = await request('db', { headers });
1818
+ if (data.ok === false) {
1819
+ setDbError(data.message || '读取数据库连接失败');
1820
+ setDbConnections([]);
1821
+ return;
1822
+ }
1823
+ const list = Array.isArray(data.connections) ? data.connections : [];
1824
+ setDbConnections(list);
1825
+ setDbError('');
1826
+ // 选中的连接被删了就退回第一个
1827
+ setDbActive((current) => (list.some((item) => item.id === current) ? current : (list[0] ? list[0].id : '')));
1828
+ } catch (e) {
1829
+ setDbError(e.message);
1830
+ }
1831
+ }, [headers]);
1832
+
1833
+ /**
1834
+ * 数据库连接只在面板可见时加载一次。
1835
+ *
1836
+ * 注意:这个 effect 必须放在 refreshDb 声明**之后**。
1837
+ * 之前把它并进上面那个主 effect 的依赖数组,而那个 effect 在 refreshDb
1838
+ * 之前执行,const 的暂时性死区直接抛 `Cannot access 'refreshDb' before
1839
+ * initialization`,整个 Panel 崩掉 -> 面板白屏。
1840
+ */
1841
+ React.useEffect(() => {
1842
+ if (!visible) return undefined;
1843
+ refreshDb();
1844
+ refreshDbAccess();
1845
+ return undefined;
1846
+ }, [visible, refreshDb, refreshDbAccess]);
1847
+
1848
+ const loadDbTables = React.useCallback(async (connectionId) => {
1849
+ if (!connectionId) { setDbTables([]); return; }
1850
+ try {
1851
+ const data = await request(`db/tables?connection_id=${encodeURIComponent(connectionId)}`, { headers });
1852
+ setDbTables(data.ok === false ? [] : (Array.isArray(data.tables) ? data.tables : []));
1853
+ if (data.ok === false) setDbError(data.message || '读取表清单失败');
1854
+ } catch (e) {
1855
+ setDbError(e.message);
1856
+ }
1857
+ }, [headers]);
1858
+
1859
+ const dbSave = async (value) => {
1860
+ setDbDbBusy('save');
1861
+ setDbError('');
1862
+ try {
1863
+ const payload = await post('db/save', { connection: value });
1864
+ if (payload.ok === false) throw new Error(payload.message || '保存失败');
1865
+ setDbForm(null);
1866
+ setNotice(payload.message || '连接已保存');
1867
+ await refreshDb();
1868
+ } catch (e) {
1869
+ setDbError(e.message);
1870
+ } finally {
1871
+ setDbDbBusy('');
1872
+ }
1873
+ };
1874
+
1875
+ const dbDelete = async (item) => {
1876
+ if (!window.confirm(`删除连接「${item.name}」?(只删除配置,不影响数据库本身)`)) return;
1877
+ setDbDbBusy('delete');
1878
+ try {
1879
+ const payload = await post('db/delete', { connection_id: item.id });
1880
+ if (payload.ok === false) throw new Error(payload.message || '删除失败');
1881
+ setNotice(payload.message || '已删除');
1882
+ setDbResult(null);
1883
+ setDbTables([]);
1884
+ await refreshDb();
1885
+ } catch (e) {
1886
+ setDbError(e.message);
1887
+ } finally {
1888
+ setDbDbBusy('');
1889
+ }
1890
+ };
1891
+
1892
+ /** 列表行的测试:同样用独立状态,不锁其它按钮 */
1893
+ const dbTest = async (item) => {
1894
+ setDbTesting(true);
1895
+ setDbError('');
1896
+ try {
1897
+ const payload = await post('db/test', { connection_id: item.id, timeout_ms: 15000 });
1898
+ if (payload.ok) setNotice(`连接「${item.name}」正常(${payload.durationMs}ms)`);
1899
+ else setDbError(payload.message || '连接失败');
1900
+ } catch (e) {
1901
+ setDbError(e.message);
1902
+ } finally {
1903
+ setDbTesting(false);
1904
+ }
1905
+ };
1906
+
1907
+ /**
1908
+ * 表单里的试连:把当前填写的内容发给后端,**不落盘**。
1909
+ * 新增时不用先保存就能验证连通性(用户报的「填完没法测试」)。
1910
+ */
1911
+ /**
1912
+ * 表单试连:把当前填写的内容发给后端,**不落盘**。
1913
+ *
1914
+ * 用独立的 dbTesting 而不是全局 dbDbBusy:达梦连不上时要等十几秒,
1915
+ * 若共用全局 busy,期间「保存/取消/编辑」全被 disabled,
1916
+ * 用户会以为界面卡死了(实际是按钮被锁)。
1917
+ */
1918
+ const dbTestDraft = async (draft) => {
1919
+ setDbTesting(true);
1920
+ setDbError('');
1921
+ setDbTestResult(null);
1922
+ try {
1923
+ const payload = await post('db/test', { connection: draft });
1924
+ setDbTestResult(payload);
1925
+ } catch (e) {
1926
+ setDbTestResult({ ok: false, message: e.message });
1927
+ } finally {
1928
+ setDbTesting(false);
1929
+ }
1930
+ };
1931
+
1932
+ const dbRun = async () => {
1933
+ if (!dbActive) { setDbError('请先选择一个连接'); return; }
1934
+ if (String(dbSql).trim() === '') { setDbError('SQL 不能为空'); return; }
1935
+ setDbDbBusy('query');
1936
+ setDbError('');
1937
+ setDbResult(null);
1938
+ try {
1939
+ const payload = await post('db/query', {
1940
+ connection_id: dbActive,
1941
+ sql: dbSql,
1942
+ readonly: dbReadonly,
1943
+ });
1944
+ setDbResult(payload);
1945
+ if (payload.ok === false) setDbError(payload.message || '执行失败');
1946
+ } catch (e) {
1947
+ setDbError(e.message);
1948
+ } finally {
1949
+ setDbDbBusy('');
1950
+ }
1951
+ };
1952
+
1953
+ const dbDescribe = async (tableName) => {
1954
+ if (!dbActive || !tableName) return;
1955
+ setDbDbBusy('describe');
1956
+ try {
1957
+ const data = await request(`db/table?connection_id=${encodeURIComponent(dbActive)}&name=${encodeURIComponent(tableName)}`, { headers });
1958
+ if (data.ok === false) { setDbError(data.message || '读取表结构失败'); return; }
1959
+ setDbTableDetail(data);
1960
+ // 点表名顺手把查询填好,省得手打
1961
+ setDbSql(`SELECT * FROM ${tableName} LIMIT 100`);
1962
+ } catch (e) {
1963
+ setDbError(e.message);
1964
+ } finally {
1965
+ setDbDbBusy('');
1966
+ }
1967
+ };
1968
+
1321
1969
  const tabs = [
1322
1970
  { id: 'apis', label: '接口', count: apis.length },
1323
1971
  { id: 'services', label: '服务', count: runningList.length },
1972
+ { id: 'db', label: '数据库', count: dbConnections.length },
1324
1973
  { id: 'settings', label: '设置', count: 0 },
1325
1974
  ];
1326
1975
 
@@ -1578,6 +2227,176 @@ window.__ModuleLoader__.load({
1578
2227
  ? 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
2228
  : h('div', { className: 'ar-kv' }, h('span', { className: 'k' }, '来源'), h('span', { className: 'v ar-muted' }, '暂无 YAML 文件,接口全部来自面板')))));
1580
2229
 
2230
+ // ---------------------------------------------------------------- 数据库面板
2231
+ const activeConn = dbConnections.find((item) => item.id === dbActive) || null;
2232
+ /** 数据库子页的一级标题:独立小胶囊,一次只看一块(和参考图的「连接…/SQL…/数据…」一致) */
2233
+ const dbSections = [
2234
+ { id: 'conn', label: '连接管理', count: dbConnections.length },
2235
+ { id: 'access', label: '项目授权', count: dbAccess ? dbAccess.authorized : 0 },
2236
+ { id: 'sql', label: 'SQL', count: 0 },
2237
+ { id: 'schema', label: '表结构', count: dbTables.length },
2238
+ ];
2239
+ const dbPane = h('div', { className: 'ar-pane' },
2240
+ h(Banner, { kind: 'err', text: dbError, onClose: () => setDbError('') }),
2241
+
2242
+ h('div', { className: 'ar-segmented' }, dbSections.map((item) => h('button', {
2243
+ key: item.id,
2244
+ className: item.id === dbSection ? 'ar-seg on' : 'ar-seg',
2245
+ onClick: () => setDbSection(item.id),
2246
+ ref: (node) => {
2247
+ // 窄面板里胶囊会横向滚动,选中的那个必须自己滚进视野
2248
+ if (node && item.id === dbSection) node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
2249
+ },
2250
+ }, item.label, item.count > 0 ? h('span', { className: 'n' }, item.count) : null))),
2251
+
2252
+ // ---- 连接 ----
2253
+ dbSection === 'conn' ? h('div', { className: 'ar-pane' },
2254
+ h('div', { className: 'ar-row' },
2255
+ h('button', {
2256
+ className: 'ar-btn tonal',
2257
+ disabled: dbDbBusy !== '',
2258
+ onClick: () => { setDbTestResult(null); setDbForm({
2259
+ id: '', name: '', type: 'sqlite', host: '', port: '',
2260
+ database: '', username: '', password: '', extraText: '',
2261
+ connectString: '', urlMode: false,
2262
+ }); },
2263
+ }, '新增连接'),
2264
+ h('span', { className: 'ar-spacer' }),
2265
+ h('button', { className: 'ar-btn ghost icon', disabled: dbDbBusy !== '', title: '重新读取连接列表', onClick: refreshDb }, '↻')),
2266
+
2267
+ dbConnections.length === 0
2268
+ ? h(Empty, {
2269
+ icon: '🗄',
2270
+ title: '还没有数据库连接',
2271
+ hint: '点「新增连接」配一个。SQLite 直接填文件路径(:memory: 表示内存库);MySQL / PostgreSQL / Oracle / 达梦填 host、端口、账号密码。',
2272
+ })
2273
+ : h('div', { className: 'ar-group' }, dbConnections.map((item) => h('div', {
2274
+ key: item.id,
2275
+ className: item.id === dbActive ? 'ar-item ar-item-on' : 'ar-item',
2276
+ onClick: () => { setDbActive(item.id); setDbResult(null); setDbTableDetail(null); loadDbTables(item.id); },
2277
+ },
2278
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
2279
+ h('span', { className: 'ar-badge brand' }, item.type),
2280
+ item.urlMode ? h('span', { className: 'ar-badge' }, 'URL') : null,
2281
+ h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name),
2282
+ item.id === dbActive ? h('span', { className: 'ar-badge' }, '当前') : null),
2283
+ h('div', {
2284
+ className: 'ar-hint ar-mono ar-cut',
2285
+ title: item.urlMode ? (item.connectString || '') : (item.type === 'sqlite' ? item.file : `${item.host}:${item.port}/${item.database}`),
2286
+ }, item.urlMode
2287
+ ? (item.connectString || '(URL 未填)')
2288
+ : (item.type === 'sqlite' ? (item.file || ':memory:') : `${item.host}:${item.port}/${item.database}`)),
2289
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
2290
+ h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '', onClick: () => dbTest(item) }, '测试'),
2291
+ h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '', onClick: () => { setDbTestResult(null); setDbForm({
2292
+ id: item.id, name: item.name, type: item.type, host: item.host,
2293
+ port: item.port ? String(item.port) : '', database: item.database,
2294
+ username: item.username, password: '', extraText: Object.keys(item.extra || {}).length ? JSON.stringify(item.extra, null, 2) : '',
2295
+ // URL 模式回显打码后的串,留空表示不改(后端会沿用原串)
2296
+ connectString: item.connectString || '', urlMode: Boolean(item.urlMode),
2297
+ }); } }, '编辑'),
2298
+ h('span', { className: 'ar-spacer' }),
2299
+ h('button', { className: 'ar-btn ghost danger', disabled: dbDbBusy !== '', onClick: () => dbDelete(item) }, '删除'))))),
2300
+
2301
+ dbForm ? h(EditDbForm, {
2302
+ value: dbForm,
2303
+ onChange: setDbForm,
2304
+ onSave: dbSave,
2305
+ onTest: dbTestDraft,
2306
+ testResult: dbTestResult,
2307
+ onCancel: () => { setDbForm(null); setDbTestResult(null); },
2308
+ busy: dbDbBusy,
2309
+ testing: dbTesting,
2310
+ }) : null)
2311
+ : null,
2312
+
2313
+ // ---- 项目授权 ----
2314
+ dbSection === 'access' ? h('div', { className: 'ar-pane' },
2315
+ h('div', { className: 'ar-hint' },
2316
+ '为当前项目授权连接。只读 = 仅查询/浏览,读写 = 允许执行写语句。未授权的连接在业务操作中一律拒绝。'),
2317
+ h('div', { className: 'ar-row' },
2318
+ h('span', { className: 'ar-hint' }, `项目:${(dbAccess && dbAccess.projectKey) || '(加载中)'}`),
2319
+ h('span', { className: 'ar-spacer' }),
2320
+ h('button', { className: 'ar-btn ghost icon', disabled: dbDbBusy !== '', title: '刷新授权状态', onClick: refreshDbAccess }, '↻')),
2321
+ h('div', { className: 'ar-hint' },
2322
+ `已授权 ${dbAccess ? dbAccess.authorized : 0}/${dbAccess ? dbAccess.total : 0} 个连接`),
2323
+
2324
+ !dbAccess || dbAccess.connections.length === 0
2325
+ ? h(Empty, {
2326
+ icon: '🔐',
2327
+ title: '还没有可授权的连接',
2328
+ hint: '先到「连接管理」里新增一个连接,再回到这里授权给当前项目。',
2329
+ })
2330
+ : h('div', { className: 'ar-group' }, dbAccess.connections.map((item) => h('div', {
2331
+ key: item.id,
2332
+ className: 'ar-item',
2333
+ },
2334
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
2335
+ h('span', { className: 'ar-badge brand' }, item.type),
2336
+ h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name),
2337
+ item.level !== 'none' ? h('span', { className: 'ar-badge' }, item.level === 'readonly' ? '只读' : '读写') : null),
2338
+ h('div', { className: 'ar-hint ar-mono ar-cut', title: item.target || '' }, item.target || ''),
2339
+ // 三档授权切换:未授权 / 只读 / 读写
2340
+ h('div', { className: 'ar-segmented', style: { padding: '0' } },
2341
+ [
2342
+ { level: 'none', label: '未授权' },
2343
+ { level: 'readonly', label: '只读' },
2344
+ { level: 'readwrite', label: '读写' },
2345
+ ].map((opt) => h('button', {
2346
+ key: opt.level,
2347
+ className: item.level === opt.level ? 'ar-seg on' : 'ar-seg',
2348
+ disabled: dbDbBusy !== '',
2349
+ onClick: () => setDbAccessLevel(item.id, opt.level),
2350
+ }, opt.label))))))
2351
+ ,
2352
+ h('div', { className: 'ar-hint' },
2353
+ '读写权限也**不能**执行 DROP / TRUNCATE / 无 WHERE 的 DELETE 等毁灭性操作——这些一律拒绝,请到数据库客户端人工处理。'))
2354
+ : null,
2355
+
2356
+ // ---- SQL ----
2357
+ dbSection === 'sql' ? h('div', { className: 'ar-pane' },
2358
+ h('div', { className: 'ar-card' },
2359
+ h('div', { className: 'ar-hint' }, activeConn
2360
+ ? `当前连接:${activeConn.name}(${activeConn.type})`
2361
+ : '先到「连接」里选一个连接'),
2362
+ h('textarea', {
2363
+ className: 'ar-ta ar-mono',
2364
+ value: dbSql,
2365
+ placeholder: 'SELECT * FROM table LIMIT 100',
2366
+ spellCheck: false,
2367
+ onChange: (e) => setDbSql(e.target.value),
2368
+ style: { minHeight: '90px' },
2369
+ }),
2370
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
2371
+ h('label', { className: 'ar-check' },
2372
+ h('input', { type: 'checkbox', checked: dbReadonly, onChange: (e) => setDbReadonly(e.target.checked) }),
2373
+ h('span', {}, '只读模式(拒绝写操作)')),
2374
+ h('span', { className: 'ar-spacer' }),
2375
+ h('button', { className: 'ar-btn primary', disabled: dbDbBusy !== '' || !dbActive, onClick: dbRun },
2376
+ dbDbBusy === 'query' ? '执行中…' : '执行')),
2377
+ dbReadonly ? null : h('div', { className: 'ar-hint', style: { color: 'var(--ar-warn)' } }, '只读模式已关闭:写操作会真实生效。')),
2378
+
2379
+ dbResult ? h(DbResultCard, { result: dbResult, onClose: () => setDbResult(null) }) : null)
2380
+ : null,
2381
+
2382
+ // ---- 表结构 ----
2383
+ dbSection === 'schema' ? h('div', { className: 'ar-pane' },
2384
+ h('div', { className: 'ar-row' },
2385
+ h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '' || !dbActive, onClick: () => loadDbTables(dbActive) }, '刷新表清单'),
2386
+ h('span', { className: 'ar-hint', style: { flex: '1 1 auto', minWidth: 0 } },
2387
+ dbTables.length > 0 ? `${dbTables.length} 个表` : '(未加载)')),
2388
+ dbTables.length === 0
2389
+ ? h('div', { className: 'ar-hint' }, dbActive ? '点「刷新表清单」查看这个库里的表。' : '先到「连接」里选一个连接。')
2390
+ : h('div', { className: 'ar-group' }, dbTables.map((item) => h('button', {
2391
+ key: item.name,
2392
+ className: 'ar-btn ghost ar-mono',
2393
+ style: { justifyContent: 'flex-start', width: '100%' },
2394
+ disabled: dbDbBusy !== '',
2395
+ onClick: () => dbDescribe(item.name),
2396
+ }, item.name))),
2397
+ dbTableDetail ? h(TableDetail, { detail: dbTableDetail, onClose: () => setDbTableDetail(null) }) : null)
2398
+ : null);
2399
+
1581
2400
  return h('div', { className: 'ar-root' },
1582
2401
  h('div', { className: 'ar-top' },
1583
2402
  h('div', { className: 'ar-tabs' }, tabs.map((item) => h('button', {
@@ -1591,6 +2410,7 @@ window.__ModuleLoader__.load({
1591
2410
  h(Banner, { kind: 'ok', text: error ? '' : notice, onClose: () => setNotice('') }),
1592
2411
  tab === 'apis' ? apisPane : null,
1593
2412
  tab === 'services' ? servicesPane : null,
2413
+ tab === 'db' ? dbPane : null,
1594
2414
  tab === 'settings' ? settingsPane : null));
1595
2415
  }
1596
2416
 
@@ -1773,7 +2593,7 @@ window.__ModuleLoader__.load({
1773
2593
  }
1774
2594
 
1775
2595
  function apply(ctx) {
1776
- installStyles(ctx);
2596
+ installStyles();
1777
2597
  const slots = typeof ctx.get === 'function' ? ctx.get('slots') : undefined;
1778
2598
  if (!slots || typeof slots.register !== 'function') return;
1779
2599