dsh-api-runner 0.3.0 → 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
@@ -32,267 +32,406 @@ window.__ModuleLoader__.load({
32
32
  * 插件升级后页面里仍是旧 CSS(表现为「改了样式但刷新没用」)。
33
33
  * 每次改样式就把版本号 +1,旧元素自然失效,新样式必定注入。
34
34
  */
35
- const STYLE_ID = 'dsh-api-runner-style-v3';
35
+ const STYLE_ID = 'dsh-api-runner-style-v5';
36
36
  const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
37
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
+
38
54
  // ---------------------------------------------------------------- 样式
39
55
  const CSS = `
40
- .ar-root { display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; box-sizing: border-box;
41
- font-size: 13px; line-height: 1.45; -webkit-font-smoothing: antialiased;
42
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
43
- --ar-accent: var(--dsw-alias-brand-primary, #0a84ff);
44
- --ar-surface: rgba(120, 120, 128, .12);
45
- --ar-surface-strong: rgba(120, 120, 128, .18);
46
- /* 选中态用的「抬起」底色:深色主题下比背景亮一点,浅色主题下接近纯白 */
47
- --ar-elevated: var(--dsw-alias-bg-elevated, rgba(255, 255, 255, .14));
48
- --ar-sep: rgba(120, 120, 128, .22);
49
- --ar-danger: var(--dsw-alias-state-error-primary, #ff453a);
50
- --ar-success: var(--dsw-alias-state-success-primary, #30d158);
51
- --ar-warn: var(--dsw-alias-state-warn-primary, #ffd60a);
52
- --ar-r-card: 12px;
53
- --ar-r-ctrl: 8px;
54
- --ar-r-pill: 6px;
55
- --ar-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
56
- --ar-ease: cubic-bezier(.25, .1, .25, 1);
57
- }
58
- @media (prefers-color-scheme: light) {
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);
61
- --ar-sep: rgba(60, 60, 67, .16);
62
- --ar-danger: var(--dsw-alias-state-error-primary, #ff3b30);
63
- --ar-success: var(--dsw-alias-state-success-primary, #34c759);
64
- --ar-warn: var(--dsw-alias-state-warn-primary, #ff9f0a); }
65
- }
66
-
67
- /* ---- 顶部:页签胶囊(参照 DSH 插件详情页的分段控件)----
68
- 两层结构刻意做得不一样,一眼能看出层级:
69
- · 顶部 ar-topbar:整条浅灰底容器,装四个页签,选中项白底 + 阴影
70
- · 一级标题 ar-segmented:独立小胶囊,不套外层容器,选中项白底
71
- */
72
- .ar-top { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; padding: 10px 12px 8px; }
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; }
77
- .ar-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
78
- border: 0; background: transparent; color: inherit; cursor: pointer; padding: 5px 8px; font: inherit; font-size: 12.5px;
79
- border-radius: var(--ar-r-pill); white-space: nowrap; opacity: .7;
80
- transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease), box-shadow .18s var(--ar-ease); }
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); }
84
- .ar-count { font-size: 10px; font-weight: 600; line-height: 1; padding: 2px 4px; border-radius: 4px;
85
- background: var(--ar-surface-strong); opacity: .85; }
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; }
104
-
105
- .ar-body { flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; padding: 2px 12px 24px;
106
- display: flex; flex-direction: column; gap: 10px; }
107
- .ar-pane { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
108
-
109
- /* ---- 排版 ---- */
110
- .ar-mono { font-family: var(--ar-mono); }
111
- .ar-muted { opacity: .6; }
112
- .ar-faint { opacity: .45; }
113
- .ar-cut { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
114
- .ar-spacer { flex: 1 1 auto; min-width: 0; }
115
- .ar-row { display: flex; align-items: center; gap: 6px; }
116
-
117
- /* 分组标题:一行小标题 + 右延细线 */
118
- .ar-sec { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
119
- .ar-sec .t { font-size: 12.5px; font-weight: 600; letter-spacing: .01em; opacity: .72; white-space: nowrap; }
120
- .ar-sec::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--ar-sep); }
121
-
122
- /* 二级页签:和顶部同一套分段控件,但用颜色区分层级——
123
- 顶部(接口/服务/设置)是灰底 + 灰白选中块,二级(已接管/运行中/…、项目分类)去掉灰底容器、
124
- 选中态用品牌色实心块。这样一眼能看出「当前在哪一层」,不会和顶部混成同一排。
125
- 注意:选中态用实心 accent 而不是半透明混合,避免依赖 color-mix,浅色/深色主题都稳。 */
126
- .ar-tabs.sub { flex: none; background: transparent; padding: 0; gap: 4px; }
127
- .ar-tabs.sub .ar-tab { padding: 4px 8px; font-size: 12px; background: var(--ar-surface); opacity: .72; }
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; }
133
- .ar-tabs.sub .ar-tab.on {
134
- background: var(--ar-accent); color: #fff; opacity: 1; font-weight: 600;
135
- box-shadow: 0 1px 3px rgba(0, 0, 0, .18);
136
- }
137
- .ar-tabs.sub .ar-count { font-size: 9.5px; padding: 1px 4px; background: var(--ar-surface-strong); opacity: .9; }
138
- .ar-tabs.sub .ar-tab.on .ar-count { background: rgba(255, 255, 255, .26); color: #fff; opacity: 1; }
139
- /* 项目多的时候横向滚动,不换行、不压扁页签 */
140
- .ar-tabs.ar-projects { overflow-x: auto; scrollbar-width: none; }
141
- .ar-tabs.ar-projects::-webkit-scrollbar { display: none; }
142
- .ar-tabs.ar-projects .ar-tab { flex: 0 0 auto; max-width: 132px; }
143
- .ar-tabs.ar-projects .ar-tab .l { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
144
-
145
- /* ---- inset grouped 卡片 ---- */
146
- .ar-card { display: flex; flex-direction: column; gap: 9px; padding: 12px;
147
- background: var(--ar-surface); border: 0; border-radius: var(--ar-r-card); }
148
- .ar-group { display: flex; flex-direction: column; background: var(--ar-surface);
149
- border-radius: var(--ar-r-card); overflow: hidden; }
150
- .ar-item { display: flex; flex-direction: column; gap: 6px; padding: 11px 12px;
151
- transition: background-color .15s var(--ar-ease); }
152
- .ar-item + .ar-item { border-top: 1px solid var(--ar-sep); }
153
- .ar-head { display: flex; align-items: flex-start; gap: 8px; }
154
- .ar-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
155
- .ar-name { font-size: 13px; font-weight: 600; }
156
-
157
- /* ---- 徽章 / 方法标 ---- */
158
- .ar-badge { display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: var(--ar-r-pill);
159
- font-size: 11px; font-weight: 500; white-space: nowrap; line-height: 1;
160
- background: var(--ar-surface-strong); opacity: .9; }
161
- .ar-badge.ok { color: var(--ar-success); background: rgba(48, 209, 88, .14); opacity: 1; }
162
- .ar-badge.warn { color: var(--ar-warn); background: rgba(255, 214, 10, .14); opacity: 1; }
163
- .ar-badge.err { color: var(--ar-danger); background: rgba(255, 69, 58, .14); opacity: 1; }
164
- .ar-badge.brand { color: var(--ar-accent); background: rgba(10, 132, 255, .14); opacity: 1; }
165
- .ar-m { display: inline-flex; align-items: center; justify-content: center; flex: none;
166
- min-width: 46px; height: 19px; padding: 0 5px; border-radius: 5px;
167
- font-family: var(--ar-mono); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; }
168
- .ar-m.get { color: #0a84ff; background: rgba(10, 132, 255, .14); }
169
- .ar-m.post { color: #34c759; background: rgba(52, 199, 89, .16); }
170
- .ar-m.put, .ar-m.patch { color: #ff9f0a; background: rgba(255, 159, 10, .16); }
171
- .ar-m.del { color: #ff3b30; background: rgba(255, 59, 48, .14); }
172
-
173
- /* ---- 地址:host 弱、path 强 ---- */
174
- .ar-url { font-family: var(--ar-mono); font-size: 11.5px; overflow-wrap: anywhere; }
175
- .ar-url .h { opacity: .5; }
176
- .ar-url .p { opacity: .95; }
177
-
178
- /* ---- 按钮:胶囊填充 ---- */
179
- .ar-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px;
180
- border: 0; border-radius: var(--ar-r-ctrl); padding: 5px 12px; font: inherit; font-size: 12.5px; font-weight: 500;
181
- cursor: pointer; white-space: nowrap; color: inherit; background: var(--ar-surface);
182
- transition: background-color .18s var(--ar-ease), filter .18s var(--ar-ease), transform .18s var(--ar-ease), opacity .18s var(--ar-ease); }
183
- .ar-btn:active:not(:disabled) { transform: scale(.97); }
184
- .ar-btn:disabled { opacity: .38; cursor: default; transform: none; }
185
- .ar-btn.primary { background: var(--ar-accent); color: #fff; }
186
- .ar-btn.tonal { background: var(--ar-surface-strong); }
187
- .ar-btn.ghost { background: transparent; opacity: .62; padding: 5px 9px; }
188
- .ar-btn.danger { color: var(--ar-danger); }
189
- .ar-btn.icon { padding: 5px; width: 28px; }
190
- @media (hover: hover) and (pointer: fine) {
191
- .ar-btn:hover:not(:disabled) { background: var(--ar-surface-strong); }
192
- .ar-btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--ar-accent); }
193
- .ar-btn.ghost:hover:not(:disabled) { background: var(--ar-surface); opacity: .95; }
194
- .ar-btn.danger:hover:not(:disabled) { background: rgba(255, 69, 58, .12); }
195
- .ar-item:hover { background: var(--ar-surface-strong); }
196
- }
197
-
198
- /* ---- 表单:无边框填充 ---- */
199
- .ar-field { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 8px; align-items: center; }
200
- .ar-field.top { align-items: start; }
201
- .ar-field > label { font-size: 12px; opacity: .62; }
202
- .ar-field.top > label { padding-top: 7px; }
203
- .ar-in, .ar-sel, .ar-ta { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px;
204
- color: inherit; background: var(--ar-surface-strong); border: 0; border-radius: var(--ar-r-ctrl);
205
- padding: 6px 10px; outline: 2px solid transparent; outline-offset: -1px;
206
- transition: outline-color .18s var(--ar-ease); }
207
- .ar-in, .ar-sel { height: 30px; padding-top: 0; padding-bottom: 0; }
208
- .ar-sel { cursor: pointer; }
209
- .ar-ta { min-height: 60px; resize: vertical; font-family: var(--ar-mono); font-size: 12px; line-height: 1.5; }
210
- .ar-in:focus, .ar-sel:focus, .ar-ta:focus { outline-color: var(--ar-accent); }
211
- .ar-in::placeholder, .ar-ta::placeholder { color: inherit; opacity: .38; }
212
- .ar-check { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 13px; }
213
- .ar-check input { width: 15px; height: 15px; margin: 0; accent-color: var(--ar-accent); cursor: pointer; flex: none; }
214
- .ar-hint { font-size: 11.5px; opacity: .55; line-height: 1.55; }
215
-
216
- /* ---- 提示条 ---- */
217
- .ar-banner { display: flex; align-items: flex-start; gap: 7px; padding: 8px 11px;
218
- border-radius: var(--ar-r-ctrl); font-size: 12.5px; line-height: 1.5;
219
- background: var(--ar-surface); }
220
- .ar-banner > span { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
221
- /* 关闭按钮做成「纯文字 ×」:无边框无底色,颜色跟着提示条走,位置钉在最后一行右侧 */
222
- .ar-banner > .ar-x { flex: none; margin-left: auto; align-self: center;
223
- appearance: none; -webkit-appearance: none; border: 0 none; background: none; box-shadow: none;
224
- color: inherit; cursor: pointer; font-family: inherit; font-size: 15px; line-height: 1;
225
- padding: 0 1px 2px; border-radius: 4px; opacity: .55;
226
- transition: opacity .16s var(--ar-ease); }
227
- .ar-banner > .ar-x:hover { opacity: .95; }
228
- .ar-banner > .ar-x:focus-visible { outline: 1px solid currentColor; outline-offset: 1px; opacity: .95; }
229
- .ar-banner.err { color: var(--ar-danger); background: rgba(255, 69, 58, .12); }
230
- .ar-banner.ok { color: var(--ar-success); background: rgba(52, 199, 89, .12); }
231
-
232
- /* ---- 响应查看 ---- */
233
- .ar-pre { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl);
234
- background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 12px; line-height: 1.55;
235
- white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; }
236
- .ar-seg { display: flex; gap: 2px; padding: 2px; background: var(--ar-surface); border-radius: var(--ar-r-pill); }
237
- .ar-seg > button { border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11.5px;
238
- padding: 3px 9px; border-radius: 5px; opacity: .62; transition: background-color .18s var(--ar-ease), opacity .18s var(--ar-ease); }
239
- .ar-seg > button:hover { opacity: .9; }
240
- .ar-seg > button.on { opacity: 1; font-weight: 600; background: var(--ar-surface-strong); }
241
-
242
- /* ---- 空态 ---- */
243
- .ar-empty { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 16px; text-align: center; }
244
- .ar-empty-icon { font-size: 22px; opacity: .3; line-height: 1; }
245
- .ar-empty-title { font-size: 13px; font-weight: 600; }
246
- .ar-empty-hint { font-size: 12px; opacity: .55; line-height: 1.6; max-width: 230px; }
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
-
259
- /* ---- 一键启动进度 ---- */
260
- .ar-batch { display: flex; flex-direction: column; gap: 6px; padding: 9px 11px; border-radius: var(--ar-r-card);
261
- background: var(--ar-surface); }
262
- .ar-batch-top { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
263
- .ar-batch-bar { height: 4px; border-radius: 2px; background: var(--ar-surface-strong); overflow: hidden; }
264
- .ar-batch-bar > i { display: block; height: 100%; border-radius: 2px; background: var(--ar-accent);
265
- transition: width .25s var(--ar-ease); }
266
- .ar-batch-line { display: flex; align-items: baseline; gap: 6px; font-size: 12px; }
267
- .ar-batch-line > .n { flex: 0 0 auto; font-family: var(--ar-mono); }
268
- .ar-batch-line.ok > .n { color: var(--ar-success); }
269
- .ar-batch-line.err > .n { color: var(--ar-danger); }
270
- .ar-batch-line.skip > .n { color: var(--ar-warn); }
271
-
272
- /* ---- 键值行 ---- */
273
- .ar-kv { display: flex; align-items: baseline; gap: 8px; font-size: 12px; }
274
- .ar-kv > .k { flex: 0 0 55px; opacity: .55; }
275
- .ar-kv > .v { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
276
-
277
- /* ---- 服务详情 / 实时日志 ---- */
278
- .ar-detail { display: flex; flex-direction: column; gap: 9px; min-width: 0; }
279
- .ar-detail-head { display: flex; align-items: center; gap: 7px; min-width: 0; }
280
- .ar-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--ar-success); }
281
- .ar-dot.off { background: var(--ar-danger); }
282
- /* 已忽略的服务整体压暗,但仍可操作(取消忽略) */
283
- .ar-ignored { opacity: .55; }
284
- /* 日志容器:宽度必须被父级约束住,否则 flex 的 min-width:auto 会让它按内容撑开,
285
- 看起来就像「不换行」(踩过:只设 white-space:pre-wrap 但父级没 min-width:0 时无效)。 */
286
- .ar-log { margin: 0; padding: 10px 11px; border-radius: var(--ar-r-ctrl); width: 100%; box-sizing: border-box;
287
- background: var(--ar-surface-strong); font-family: var(--ar-mono); font-size: 11.5px; line-height: 1.62;
288
- white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
289
- max-height: 58vh; min-height: 140px; overflow-y: auto; overflow-x: hidden; tab-size: 4; }
290
- .ar-log.nowrap { white-space: pre; overflow-wrap: normal; word-break: normal; overflow-x: auto; }
291
- .ar-log:empty::before { content: '(暂无日志输出)'; opacity: .45; }
292
-
293
- @media (prefers-reduced-motion: reduce) {
294
- .ar-root * { transition: none !important; animation: none !important; }
295
- }
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
+
296
435
  `;
297
436
 
298
437
  /**
@@ -547,7 +686,7 @@ window.__ModuleLoader__.load({
547
686
  h('input', { type: 'checkbox', checked: value.isLogin, onChange: (e) => set('isLogin', e.target.checked) }),
548
687
  h('span', {}, '这是登录接口,调用成功后保存 token')),
549
688
  value.isLogin
550
- ? h('div', { className: 'ar-card', style: { background: 'var(--ar-surface-strong)' } },
689
+ ? h('div', { className: 'ar-card', style: { background: 'var(--ar-surface-2)' } },
551
690
  h(Field, { label: 'token 路径' }, h('input', { className: 'ar-in', value: value.tokenPath, placeholder: '响应 JSON 路径,如 data.token', onChange: (e) => set('tokenPath', e.target.value) })),
552
691
  h(Field, { label: '注入头' }, h('input', { className: 'ar-in', value: value.authHeader, placeholder: 'Authorization', onChange: (e) => set('authHeader', e.target.value) })),
553
692
  h(Field, { label: '前缀' }, h('input', { className: 'ar-in', value: value.prefix, placeholder: 'Bearer (不需要就留空)', onChange: (e) => set('prefix', e.target.value) })),
@@ -666,24 +805,77 @@ window.__ModuleLoader__.load({
666
805
  /**
667
806
  * 数据库连接编辑表单。
668
807
  *
669
- * 密码框用 type=password 且编辑时不回填:服务端只回传掩码,
808
+ * 三种录入方式:
809
+ * - sqlite:只填文件路径
810
+ * - 分字段:host / 端口 / 库名 / 用户名 / 密码
811
+ * - URL:整条连接串(dm://user:pass@host:port?opts)
812
+ *
813
+ * 端口输入框单独占一行、不固定像素宽——之前和 host 挤在一行、
814
+ * 宽度写死 90px,在窄面板里被压变形(用户报的「端口位置有 bug」)。
815
+ *
816
+ * 密码框 type=password 且编辑时不回填:服务端只回传掩码,
670
817
  * 留空表示「不修改密码」,避免编辑别处字段时把密码清掉。
671
818
  */
672
819
  function EditDbForm(props) {
673
- const { value, onChange, onSave, onCancel, busy } = props;
820
+ const { value, onChange, onSave, onTest, onCancel, busy, testing, testResult } = props;
674
821
  const set = (key, next) => onChange({ ...value, [key]: next });
675
822
  const isSqlite = value.type === 'sqlite';
676
- const needsHost = !isSqlite;
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
+
677
862
  return h('div', { className: 'ar-card' },
678
863
  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)))),
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:只有文件路径
687
879
  isSqlite
688
880
  ? h(Field, { label: '文件路径' }, h('input', {
689
881
  className: 'ar-in ar-mono', value: value.database,
@@ -691,66 +883,95 @@ window.__ModuleLoader__.load({
691
883
  onChange: (e) => set('database', e.target.value),
692
884
  }))
693
885
  : h('div', null,
694
- h('div', { className: 'ar-row' },
695
- h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
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 单独一行,不再和端口挤
696
907
  h(Field, { label: 'host' }, h('input', {
697
908
  className: 'ar-in ar-mono', value: value.host, placeholder: '127.0.0.1',
698
909
  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
- }))),
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
+
718
938
  h(Field, { label: '额外参数', top: true }, h('textarea', {
719
939
  className: 'ar-ta ar-mono', value: value.extraText,
720
940
  placeholder: '可选,JSON:{"ssl":true}',
721
941
  onChange: (e) => set('extraText', e.target.value),
722
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
+
723
954
  h('div', { className: 'ar-hint' }, '密码会加密后保存,列表与工具输出只显示掩码。'),
724
- h('div', { className: 'ar-row' },
955
+ h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
725
956
  h('button', {
726
957
  className: 'ar-btn primary',
727
- disabled: Boolean(busy),
958
+ // 保存只受「保存中」影响;试连进行中仍可保存
959
+ disabled: busy === 'save',
728
960
  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
- });
961
+ const payload = toPayload();
962
+ if (payload === undefined) return;
963
+ onSave(payload);
752
964
  },
753
- }, '保存'),
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' }),
754
975
  h('button', { className: 'ar-btn ghost', onClick: onCancel }, '取消')));
755
976
  }
756
977
 
@@ -1065,7 +1286,7 @@ window.__ModuleLoader__.load({
1065
1286
  typeof payload.durationMs === 'number' ? h('span', { className: 'ar-hint ar-mono' }, `${payload.durationMs} ms`) : null,
1066
1287
  h('span', { className: 'ar-hint ar-mono' }, byteSize(json !== undefined ? json : payload.body)),
1067
1288
  h('span', { className: 'ar-spacer' }),
1068
- 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))),
1069
1290
  h('button', { className: 'ar-btn ghost icon', title: '复制这段内容', onClick: () => copyText(content) }, '⧉'),
1070
1291
  h('button', { className: 'ar-btn ghost icon', title: '关闭', onClick: onClose }, '×')),
1071
1292
  h('div', { className: 'ar-row', style: { minWidth: 0 } },
@@ -1121,6 +1342,12 @@ window.__ModuleLoader__.load({
1121
1342
  const [dbDbBusy, setDbDbBusy] = React.useState('');
1122
1343
  /** 数据库子页的一级标题:conn(连接)/ sql / schema(表结构) */
1123
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);
1124
1351
 
1125
1352
  const headers = React.useMemo(() => ({ 'Content-Type': 'application/json', 'x-dsh-session-id': sessionId }), [sessionId]);
1126
1353
  const get = React.useCallback(async (path) => {
@@ -1556,6 +1783,35 @@ window.__ModuleLoader__.load({
1556
1783
  * 数据库连接是跨项目的(同一套库常被多个项目共用),
1557
1784
  * 所以这里不跟着「接口」页的项目页签走,始终操作全局连接列表。
1558
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
+
1559
1815
  const refreshDb = React.useCallback(async () => {
1560
1816
  try {
1561
1817
  const data = await request('db', { headers });
@@ -1585,8 +1841,9 @@ window.__ModuleLoader__.load({
1585
1841
  React.useEffect(() => {
1586
1842
  if (!visible) return undefined;
1587
1843
  refreshDb();
1844
+ refreshDbAccess();
1588
1845
  return undefined;
1589
- }, [visible, refreshDb]);
1846
+ }, [visible, refreshDb, refreshDbAccess]);
1590
1847
 
1591
1848
  const loadDbTables = React.useCallback(async (connectionId) => {
1592
1849
  if (!connectionId) { setDbTables([]); return; }
@@ -1632,17 +1889,43 @@ window.__ModuleLoader__.load({
1632
1889
  }
1633
1890
  };
1634
1891
 
1892
+ /** 列表行的测试:同样用独立状态,不锁其它按钮 */
1635
1893
  const dbTest = async (item) => {
1636
- setDbDbBusy('test');
1894
+ setDbTesting(true);
1637
1895
  setDbError('');
1638
1896
  try {
1639
- const payload = await post('db/test', { connection_id: item.id });
1897
+ const payload = await post('db/test', { connection_id: item.id, timeout_ms: 15000 });
1640
1898
  if (payload.ok) setNotice(`连接「${item.name}」正常(${payload.durationMs}ms)`);
1641
1899
  else setDbError(payload.message || '连接失败');
1642
1900
  } catch (e) {
1643
1901
  setDbError(e.message);
1644
1902
  } finally {
1645
- setDbDbBusy('');
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);
1646
1929
  }
1647
1930
  };
1648
1931
 
@@ -1948,7 +2231,8 @@ window.__ModuleLoader__.load({
1948
2231
  const activeConn = dbConnections.find((item) => item.id === dbActive) || null;
1949
2232
  /** 数据库子页的一级标题:独立小胶囊,一次只看一块(和参考图的「连接…/SQL…/数据…」一致) */
1950
2233
  const dbSections = [
1951
- { id: 'conn', label: '连接', count: dbConnections.length },
2234
+ { id: 'conn', label: '连接管理', count: dbConnections.length },
2235
+ { id: 'access', label: '项目授权', count: dbAccess ? dbAccess.authorized : 0 },
1952
2236
  { id: 'sql', label: 'SQL', count: 0 },
1953
2237
  { id: 'schema', label: '表结构', count: dbTables.length },
1954
2238
  ];
@@ -1971,10 +2255,11 @@ window.__ModuleLoader__.load({
1971
2255
  h('button', {
1972
2256
  className: 'ar-btn tonal',
1973
2257
  disabled: dbDbBusy !== '',
1974
- onClick: () => setDbForm({
2258
+ onClick: () => { setDbTestResult(null); setDbForm({
1975
2259
  id: '', name: '', type: 'sqlite', host: '', port: '',
1976
2260
  database: '', username: '', password: '', extraText: '',
1977
- }),
2261
+ connectString: '', urlMode: false,
2262
+ }); },
1978
2263
  }, '新增连接'),
1979
2264
  h('span', { className: 'ar-spacer' }),
1980
2265
  h('button', { className: 'ar-btn ghost icon', disabled: dbDbBusy !== '', title: '重新读取连接列表', onClick: refreshDb }, '↻')),
@@ -1992,21 +2277,80 @@ window.__ModuleLoader__.load({
1992
2277
  },
1993
2278
  h('div', { className: 'ar-row', style: { flexWrap: 'wrap', gap: '5px' } },
1994
2279
  h('span', { className: 'ar-badge brand' }, item.type),
2280
+ item.urlMode ? h('span', { className: 'ar-badge' }, 'URL') : null,
1995
2281
  h('span', { className: 'ar-name ar-cut', style: { flex: '0 1 auto' } }, item.name),
1996
2282
  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}`),
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}`)),
1999
2289
  h('div', { className: 'ar-row', style: { flexWrap: 'wrap' } },
2000
2290
  h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '', onClick: () => dbTest(item) }, '测试'),
2001
- h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '', onClick: () => setDbForm({
2291
+ h('button', { className: 'ar-btn ghost', disabled: dbDbBusy !== '', onClick: () => { setDbTestResult(null); setDbForm({
2002
2292
  id: item.id, name: item.name, type: item.type, host: item.host,
2003
2293
  port: item.port ? String(item.port) : '', database: item.database,
2004
2294
  username: item.username, password: '', extraText: Object.keys(item.extra || {}).length ? JSON.stringify(item.extra, null, 2) : '',
2005
- }) }, '编辑'),
2295
+ // URL 模式回显打码后的串,留空表示不改(后端会沿用原串)
2296
+ connectString: item.connectString || '', urlMode: Boolean(item.urlMode),
2297
+ }); } }, '编辑'),
2006
2298
  h('span', { className: 'ar-spacer' }),
2007
2299
  h('button', { className: 'ar-btn ghost danger', disabled: dbDbBusy !== '', onClick: () => dbDelete(item) }, '删除'))))),
2008
2300
 
2009
- dbForm ? h(EditDbForm, { value: dbForm, onChange: setDbForm, onSave: dbSave, onCancel: () => setDbForm(null), busy: dbDbBusy }) : null)
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 等毁灭性操作——这些一律拒绝,请到数据库客户端人工处理。'))
2010
2354
  : null,
2011
2355
 
2012
2356
  // ---- SQL ----