koishi-plugin-adapter-onebot-multi 1.0.6 → 2.0.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/page.vue CHANGED
@@ -1,1090 +1,624 @@
1
- <template>
2
- <k-layout>
3
- <div class="pop-wrapper">
4
- <div v-if="loading" class="pop-loading">
5
- <div class="comic-text">LOADING...</div>
6
- </div>
7
- <div v-else class="pop-container">
8
-
9
- <!-- HEADER -->
10
- <header class="pop-header">
11
- <div class="brand">
12
- <div class="logo">✦</div>
13
- <div class="title-box">
14
- <h1>ONEBOT 控制中心</h1>
15
- <p>实例管理与负载分发路由</p>
16
- </div>
17
- </div>
18
-
19
- <nav class="pop-tabs">
20
- <button class="pop-btn" :class="{ active: activeTab === 'bots' }" @click="activeTab = 'bots'">Bot 管理</button>
21
- <button class="pop-btn" :class="{ active: activeTab === 'balance' }" @click="activeTab = 'balance'">负载均衡</button>
22
- <button class="pop-btn action-btn" @click="refreshAll">刷新状态</button>
23
- </nav>
24
- </header>
25
-
26
- <!-- BOTS TAB -->
27
- <section v-show="activeTab === 'bots'" class="pop-panel bots-panel">
28
- <div class="panel-header">
29
- <div class="panel-title">
30
- <h2>Bot 状态</h2>
31
- <p>查看与管理当前运行实例</p>
32
- </div>
33
- <button class="pop-btn primary-btn" @click="openCreate">新增节点</button>
34
- </div>
35
-
36
- <div v-if="bots.length === 0" class="empty-state">
37
- <div class="comic-speech">无可用实例。请点击“新增节点”以配置。</div>
38
- </div>
39
-
40
- <div v-else class="bot-grid">
41
- <article v-for="bot in bots" :key="bot.selfId" class="bot-card" :class="{ disabled: bot.enabled === false }">
42
- <div class="bot-header">
43
- <img class="bot-avatar" :src="bot.avatarUrl || ''" alt="avatar" />
44
- <div class="bot-info">
45
- <div class="bot-name">{{ bot.name || `Bot ${bot.selfId}` }}</div>
46
- <div class="bot-sub">{{ bot.nickname || '未知' }} | {{ bot.protocol === 'ws-reverse' ? '反向 WebSocket' : bot.protocol || '反向 WebSocket' }}</div>
47
- <div class="bot-id">{{ bot.selfId }}</div>
48
- </div>
49
- <div class="status-badge" :class="`status-${bot.status || 'offline'}`">
50
- {{ statusText(bot.status) }}
51
- </div>
52
- </div>
53
-
54
- <div class="bot-stats">
55
- <div class="stat-box"><span>群组</span><strong>{{ bot.groupCount ?? '-' }}</strong></div>
56
- <div class="stat-box"><span>好友</span><strong>{{ bot.friendCount ?? '-' }}</strong></div>
57
- <div class="stat-box"><span>收</span><strong>{{ bot.messageReceived ?? '-' }}</strong></div>
58
- <div class="stat-box"><span>发</span><strong>{{ bot.messageSent ?? '-' }}</strong></div>
1
+ <template>
2
+ <k-layout>
3
+ <a class="skip-link" href="#main-content">跳到主要内容</a>
4
+ <div class="page-shell">
5
+ <div v-if="loading" class="loading-state" aria-live="polite">正在加载…</div>
6
+
7
+ <main v-else id="main-content" class="page-body">
8
+ <header class="hero-card">
9
+ <div class="hero-copy">
10
+ <p class="hero-kicker">OneBot Multi</p>
11
+ <h1>Bot 管理中心</h1>
12
+ <p>管理 ws 客户端、ws 服务器与基础负载均衡规则</p>
13
+ </div>
14
+ <div class="hero-actions">
15
+ <button type="button" class="action-button secondary" @click="refreshAll">刷新状态</button>
16
+ <button type="button" class="action-button primary" @click="openCreate('client')">新增 ws 客户端</button>
17
+ <button type="button" class="action-button primary" @click="openCreate('server')">新增 ws 服务器</button>
18
+ </div>
19
+ </header>
20
+
21
+ <nav class="tab-bar" aria-label="页面分区">
22
+ <button type="button" class="tab-button" :class="{ active: activeTab === 'bots' }" @click="activeTab = 'bots'">Bot 管理</button>
23
+ <button type="button" class="tab-button" :class="{ active: activeTab === 'balance' }" @click="activeTab = 'balance'">负载均衡</button>
24
+ </nav>
25
+
26
+ <section v-show="activeTab === 'bots'" class="panel-card">
27
+ <div class="panel-header">
28
+ <div>
29
+ <h2>Bot 状态</h2>
30
+ <p>查看每个实例的在线状态、收发统计与连接方式</p>
31
+ </div>
32
+ </div>
33
+
34
+ <div v-if="bots.length === 0" class="empty-card">暂无实例,点击上方按钮新增 ws 客户端或 ws 服务器。</div>
35
+
36
+ <div v-else class="bot-sections">
37
+ <section v-if="reverseServerGroups.length" class="bot-section-block">
38
+ <div class="section-title-row">
39
+ <h3>ws 服务器</h3>
40
+ <span class="section-note">按接入路径聚合,自动挂载连接进来的 QQ</span>
59
41
  </div>
60
-
61
- <div class="bot-meta">
62
- <div><strong>终端:</strong> {{ bot.endpoint || '-' }}</div>
63
- <div><strong>路径:</strong> {{ bot.path || '/onebot' }}</div>
64
- </div>
65
-
66
- <div class="bot-actions">
67
- <button class="pop-btn mini" @click="openEdit(bot)">编辑</button>
68
- <button class="pop-btn mini" @click="toggleBot(bot)">{{ bot.enabled === false ? '启用' : '禁用' }}</button>
69
- <button class="pop-btn mini" @click="restartBot(bot)">重启</button>
70
- <button class="pop-btn mini" @click="openProfile(bot)">昵称</button>
71
- <button class="pop-btn mini" @click="openAvatar(bot)">头像</button>
72
- <button class="pop-btn mini danger" @click="removeBot(bot)">删除</button>
73
- </div>
74
- </article>
75
- </div>
76
-
77
- <!-- MERGED RUNTIME SETTINGS -->
78
- <div class="panel-header" style="margin-top: 40px;">
79
- <div class="panel-title">
80
- <h2>运行参数</h2>
81
- <p>调整核心连接与重试策略</p>
82
- </div>
83
- </div>
84
-
85
- <div class="form-grid">
86
- <label class="pop-label">
87
- 超时时间 (毫秒)
88
- <input v-model.number="runtime.responseTimeout" type="number" class="pop-input">
89
- </label>
90
- <label class="pop-label">
91
- 心跳间隔 (毫秒)
92
- <input v-model.number="runtime.heartbeatInterval" type="number" class="pop-input">
93
- </label>
94
- <label class="pop-label">
95
- 重试次数
96
- <input v-model.number="runtime.retryTimes" type="number" class="pop-input">
97
- </label>
98
- <label class="pop-label">
99
- 重试间隔 (毫秒)
100
- <input v-model.number="runtime.retryInterval" type="number" class="pop-input">
101
- </label>
102
- <label class="pop-label">
103
- 懒重试 (毫秒)
104
- <input v-model.number="runtime.retryLazy" type="number" class="pop-input">
105
- </label>
106
- <label class="pop-label pop-checkbox" style="align-self: center; margin-top: 24px;">
107
- <input v-model="runtime.advanced.splitMixedContent" type="checkbox" class="pop-check">
108
- <span>拆分混合内容</span>
109
- </label>
110
- </div>
111
-
112
- <div class="panel-footer">
113
- <button class="pop-btn primary-btn" @click="saveRuntime">保存配置</button>
114
- </div>
115
- </section>
116
-
117
- <!-- LOAD BALANCE TAB -->
118
- <section v-show="activeTab === 'balance'" class="pop-panel balance-panel">
119
- <div class="panel-header">
120
- <div class="panel-title">
121
- <h2>负载均衡</h2>
122
- <p>配置全局流量分发规则</p>
123
- </div>
124
- </div>
125
-
126
- <div class="form-grid">
127
- <label class="pop-label pop-checkbox">
128
- <input v-model="loadBalance.enabled" type="checkbox" class="pop-check">
129
- <span>启用负载均衡</span>
130
- </label>
131
- <label class="pop-label">
132
- 均衡间隔 (秒)
133
- <input v-model.number="loadBalance.balanceInterval" type="number" class="pop-input">
134
- </label>
135
- <label class="pop-label">
136
- 过滤模式
137
- <select v-model="loadBalance.channelFilterMode" class="pop-select">
138
- <option value="blacklist">黑名单</option>
139
- <option value="whitelist">白名单</option>
140
- </select>
141
- </label>
142
- <label class="pop-label">
143
- 默认最大负载 (0=无限)
144
- <input v-model.number="loadBalance.defaultMaxLoad" type="number" class="pop-input">
145
- </label>
146
- <label class="pop-label">
147
- 未分配值
148
- <input v-model="loadBalance.unassignedValue" type="text" class="pop-input">
149
- </label>
150
- </div>
42
+ <div class="server-group-list">
43
+ <article v-for="group in reverseServerGroups" :key="group.path" class="server-group-card">
44
+ <div class="server-group-header">
45
+ <div>
46
+ <div class="server-group-title-row">
47
+ <div class="server-group-title">{{ group.template?.name || 'ws 服务器' }}</div>
48
+ <span class="server-tag">共享 ws 服务器</span>
49
+ <span class="server-tag">{{ group.template?.enabled === false ? '已禁用' : '已启用' }}</span>
50
+ </div>
51
+ <div class="server-group-subtitle">接入路径</div>
52
+ <div class="server-group-path" translate="no">{{ group.path }}</div>
53
+ </div>
54
+ <div class="server-group-meta">
55
+ <span class="server-count">{{ group.bots.length }} 个 QQ</span>
56
+ <span class="server-count">{{ group.onlineCount }} 在线</span>
57
+ </div>
58
+ </div>
151
59
 
152
- <div class="split-grid">
153
- <div class="pop-label">
154
- 群黑名单
155
- <div class="list-summary-box">
156
- <span class="list-count">已配置 {{ countLines(blacklistText) }} 个群</span>
157
- <button class="pop-btn mini action-btn" @click="openListEditor('blacklist')">编辑</button>
158
- </div>
159
- </div>
160
- <div class="pop-label">
161
- 群白名单
162
- <div class="list-summary-box">
163
- <span class="list-count">已配置 {{ countLines(whitelistText) }} 个群</span>
164
- <button class="pop-btn mini action-btn" @click="openListEditor('whitelist')">编辑</button>
165
- </div>
166
- </div>
167
- <div class="pop-label">
168
- 优先群聊
169
- <div class="list-summary-box">
170
- <span class="list-count">已配置 {{ countLines(priorityText) }} 个群</span>
171
- <button class="pop-btn mini action-btn" @click="openListEditor('priority')">编辑</button>
172
- </div>
173
- </div>
174
-
175
- <div class="pop-table-editor">
176
- <div class="table-head">
177
- <span>节点最大负载</span>
178
- <button class="pop-btn mini" @click="addBotMaxRow">添加行</button>
179
- </div>
180
- <div class="table-body">
181
- <div v-if="botMaxLoadRows.length === 0" class="empty-row">暂无规则。</div>
182
- <div v-for="(row, idx) in botMaxLoadRows" :key="`max-${idx}`" class="editor-row">
183
- <select v-model="row.botId" class="pop-select mini-input">
184
- <option disabled value="">选择 Bot</option>
185
- <option v-for="bot in bots" :key="bot.selfId" :value="bot.selfId">
186
- {{ bot.name || bot.nickname || bot.selfId }}
187
- </option>
188
- </select>
189
- <input v-model.number="row.maxLoad" type="number" min="0" placeholder="最大值" class="pop-input mini-input" />
190
- <button class="pop-btn mini danger" @click="removeBotMaxRow(idx)">X</button>
191
- </div>
192
- </div>
193
- </div>
194
-
195
- <div class="pop-table-editor full-width">
196
- <div class="table-head">
197
- <span>群聊专属节点</span>
198
- <button class="pop-btn mini" @click="addChannelPriorityRow">添加行</button>
199
- </div>
200
- <div class="table-body">
201
- <div v-if="channelPriorityRows.length === 0" class="empty-row">暂无规则。</div>
202
- <div v-for="(row, idx) in channelPriorityRows" :key="`priority-${idx}`" class="editor-row" style="display: flex; flex-direction: column;">
203
- <div style="display: flex; gap: 8px;">
204
- <input v-model="row.channelId" type="text" placeholder="群号" class="pop-input mini-input" style="flex: 1;" />
205
- <button class="pop-btn mini danger" @click="removeChannelPriorityRow(idx)">X</button>
60
+ <div v-if="group.template" class="card-actions section-actions">
61
+ <button type="button" class="mini-button" @click="openEdit(group.template)">编辑服务器</button>
62
+ <button type="button" class="mini-button" @click="toggleBot(group.template)">{{ group.template.enabled === false ? '启用服务器' : '禁用服务器' }}</button>
63
+ <button type="button" class="mini-button danger" @click="removeBot(group.template)">删除服务器</button>
206
64
  </div>
207
- <div class="bot-checkbox-group" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;">
208
- <label v-for="bot in bots" :key="bot.selfId" class="pop-checkbox mini-checkbox">
209
- <input type="checkbox" :value="bot.selfId" v-model="row.botIds" class="pop-check mini-check">
210
- <span>{{ bot.name || bot.nickname || bot.selfId }}</span>
211
- </label>
65
+
66
+ <div v-if="group.bots.length === 0" class="empty-row">暂无接入的 QQ</div>
67
+ <div v-else class="bot-grid inner-bot-grid">
68
+ <article v-for="bot in group.bots" :key="bot.selfId" class="bot-card" :class="{ disabled: bot.enabled === false }">
69
+ <div class="bot-card-header">
70
+ <img class="bot-avatar" :src="bot.avatarUrl || ''" alt="Bot 头像" width="64" height="64" loading="lazy">
71
+ <div class="bot-summary">
72
+ <div class="bot-name">{{ bot.name || `Bot ${bot.selfId}` }}</div>
73
+ <div class="bot-subtitle">{{ bot.nickname || '未获取昵称' }}</div>
74
+ <div class="bot-meta-line">挂载到 ws 服务器</div>
75
+ <div class="bot-id" translate="no">{{ bot.selfId }}</div>
76
+ </div>
77
+ <span class="status-pill" :class="`status-${bot.status || 'offline'}`">{{ statusText(bot.status) }}</span>
78
+ </div>
79
+
80
+ <div class="stat-grid">
81
+ <div class="stat-item"><span>群组</span><strong>{{ bot.groupCount ?? '-' }}</strong></div>
82
+ <div class="stat-item"><span>好友</span><strong>{{ bot.friendCount ?? '-' }}</strong></div>
83
+ <div class="stat-item"><span>接收</span><strong>{{ bot.messageReceived ?? '-' }}</strong></div>
84
+ <div class="stat-item"><span>发送</span><strong>{{ bot.messageSent ?? '-' }}</strong></div>
85
+ </div>
86
+
87
+ <div class="detail-card">
88
+ <div><strong>接入路径:</strong>{{ bot.path || '/onebot' }}</div>
89
+ </div>
90
+
91
+ <div class="card-actions">
92
+ <button type="button" class="mini-button" @click="openEdit(bot)">编辑</button>
93
+ <button type="button" class="mini-button" @click="toggleBot(bot)">{{ bot.enabled === false ? '启用' : '禁用' }}</button>
94
+ <button type="button" class="mini-button" @click="restartBot(bot)">重启</button>
95
+ <button type="button" class="mini-button" @click="openProfile(bot)">昵称</button>
96
+ <button type="button" class="mini-button" @click="openAvatar(bot)">头像</button>
97
+ <button type="button" class="mini-button danger" @click="removeBot(bot)">删除</button>
98
+ </div>
99
+ </article>
212
100
  </div>
213
- </div>
101
+ </article>
214
102
  </div>
215
- </div>
216
- </div>
217
-
218
- <div class="panel-footer">
219
- <button class="pop-btn primary-btn" @click="saveLoadBalance">保存规则</button>
220
- </div>
221
- </section>
222
- </div>
103
+ </section>
223
104
 
224
- <!-- MODALS -->
225
- <!-- Create/Edit Bot -->
226
- <div v-if="editing" class="pop-modal-overlay" @click.self="editing = null">
227
- <div class="pop-modal comic-panel">
228
- <div class="comic-burst">★</div>
229
- <h3>{{ editingMode === 'create' ? '创建节点' : '编辑节点' }}</h3>
230
- <div class="form-grid modal-grid">
231
- <label class="pop-label">名称<input v-model="editing.name" type="text" class="pop-input" placeholder="选填"></label>
232
- <label class="pop-label hidden">自身 ID<input v-model="editing.selfId" type="text" class="pop-input"></label>
233
- <label class="pop-label">访问密钥<input v-model="editing.token" type="text" class="pop-input"></label>
234
- <label class="pop-label">协议
235
- <select v-model="editing.protocol" class="pop-select">
236
- <option value="ws-reverse">反向 WebSocket</option>
237
- <option value="ws">ws</option>
238
- </select>
239
- </label>
240
- <label class="pop-label">终端地址 (ws)<input v-model="editing.endpoint" type="text" class="pop-input"></label>
241
- <label class="pop-label">Path (反向 WebSocket)<input v-model="editing.path" type="text" class="pop-input"></label>
242
- <label class="pop-label pop-checkbox"><input v-model="editing.enabled" type="checkbox" class="pop-check"><span>已启用</span></label>
243
- </div>
244
- <div class="modal-actions">
245
- <button class="pop-btn primary-btn" @click="saveBot">保存</button>
246
- <button class="pop-btn" @click="editing = null">取消</button>
247
- </div>
248
- </div>
249
- </div>
105
+ <section v-if="wsClients.length" class="bot-section-block">
106
+ <div class="section-title-row">
107
+ <h3>ws 客户端</h3>
108
+ <span class="section-note">主动连接到外部 OneBot 服务</span>
109
+ </div>
110
+ <div class="bot-grid">
111
+ <article v-for="bot in wsClients" :key="bot.selfId" class="bot-card" :class="{ disabled: bot.enabled === false }">
112
+ <div class="bot-card-header">
113
+ <img class="bot-avatar" :src="bot.avatarUrl || ''" alt="Bot 头像" width="64" height="64" loading="lazy">
114
+ <div class="bot-summary">
115
+ <div class="bot-name">{{ bot.name || `Bot ${bot.selfId}` }}</div>
116
+ <div class="bot-subtitle">{{ bot.nickname || '未获取昵称' }}</div>
117
+ <div class="bot-meta-line">ws 客户端</div>
118
+ <div class="bot-id" translate="no">{{ bot.selfId }}</div>
119
+ </div>
120
+ <span class="status-pill" :class="`status-${bot.status || 'offline'}`">{{ statusText(bot.status) }}</span>
121
+ </div>
250
122
 
251
- <!-- Profile Modal -->
252
- <div v-if="profileTarget" class="pop-modal-overlay" @click.self="profileTarget = null">
253
- <div class="pop-modal comic-panel">
254
- <h3>修改昵称</h3>
255
- <p class="modal-sub">{{ profileTarget.selfId }}</p>
256
- <label class="pop-label">昵称<input v-model="profileNickname" type="text" class="pop-input"></label>
257
- <div class="modal-actions">
258
- <button class="pop-btn primary-btn" @click="saveProfile">保存</button>
259
- <button class="pop-btn" @click="profileTarget = null">取消</button>
260
- </div>
261
- </div>
262
- </div>
123
+ <div class="stat-grid">
124
+ <div class="stat-item"><span>群组</span><strong>{{ bot.groupCount ?? '-' }}</strong></div>
125
+ <div class="stat-item"><span>好友</span><strong>{{ bot.friendCount ?? '-' }}</strong></div>
126
+ <div class="stat-item"><span>接收</span><strong>{{ bot.messageReceived ?? '-' }}</strong></div>
127
+ <div class="stat-item"><span>发送</span><strong>{{ bot.messageSent ?? '-' }}</strong></div>
128
+ </div>
263
129
 
264
- <!-- Avatar Modal -->
265
- <div v-if="avatarTarget" class="pop-modal-overlay" @click.self="avatarTarget = null">
266
- <div class="pop-modal comic-panel">
267
- <h3>修改头像</h3>
268
- <p class="modal-sub">{{ avatarTarget.selfId }}</p>
269
- <label class="pop-label">
270
- 选择图片文件
271
- <input type="file" accept="image/*" class="pop-file-input" @change="onAvatarFileChange">
272
- </label>
273
- <div class="modal-actions">
274
- <button class="pop-btn primary-btn" @click="saveAvatar">保存</button>
275
- <button class="pop-btn" @click="avatarTarget = null">取消</button>
276
- </div>
277
- </div>
278
- </div>
130
+ <div class="detail-card">
131
+ <div><strong>连接地址:</strong>{{ bot.endpoint || '-' }}</div>
132
+ </div>
279
133
 
280
- <!-- List Editor Modal -->
281
- <div v-if="listEditorTarget" class="pop-modal-overlay" @click.self="listEditorTarget = null">
282
- <div class="pop-modal comic-panel">
283
- <div class="comic-burst" style="background: var(--pop-secondary); color: var(--pop-text);">✎</div>
284
- <h3>{{ listEditorTitle }}</h3>
285
- <p class="modal-sub">每行输入一个群号(仅保留数字)</p>
286
- <textarea v-model="listEditorText" rows="10" class="pop-textarea" style="width: 100%;"></textarea>
287
- <div class="modal-actions">
288
- <button class="pop-btn primary-btn" @click="saveListEditor">确认保存</button>
289
- <button class="pop-btn" @click="listEditorTarget = null">取消</button>
134
+ <div class="card-actions">
135
+ <button type="button" class="mini-button" @click="openEdit(bot)">编辑</button>
136
+ <button type="button" class="mini-button" @click="toggleBot(bot)">{{ bot.enabled === false ? '启用' : '禁用' }}</button>
137
+ <button type="button" class="mini-button" @click="restartBot(bot)">重启</button>
138
+ <button type="button" class="mini-button" @click="openProfile(bot)">昵称</button>
139
+ <button type="button" class="mini-button" @click="openAvatar(bot)">头像</button>
140
+ <button type="button" class="mini-button danger" @click="removeBot(bot)">删除</button>
141
+ </div>
142
+ </article>
143
+ </div>
144
+ </section>
290
145
  </div>
291
- </div>
292
- </div>
293
-
294
- <!-- Toast -->
295
- <div v-if="toast.show" class="pop-toast comic-panel" :class="toast.type">
296
- <span v-if="toast.type === 'success'">✓</span>
297
- <span v-else>✕</span>
298
- {{ toast.message }}
299
- </div>
300
- </div>
301
- </k-layout>
302
- </template>
303
-
304
- <script setup lang="ts">
305
- import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
306
- import { send } from '@koishijs/client'
307
-
308
- type BotStatus = 'online' | 'offline' | 'connecting'
309
-
310
- interface ManagedBot {
311
- selfId?: string
312
- token?: string
313
- protocol?: 'ws' | 'ws-reverse'
314
- endpoint?: string
315
- path?: string
316
- name?: string
317
- enabled?: boolean
318
- status?: BotStatus
319
- nickname?: string
320
- avatarUrl?: string
321
- groupCount?: number
322
- friendCount?: number
323
- messageReceived?: number
324
- messageSent?: number
325
- }
326
-
327
- interface RuntimeConfig {
328
- responseTimeout: number
329
- heartbeatInterval: number
330
- retryTimes: number
331
- retryInterval: number
332
- retryLazy: number
333
- advanced: {
334
- splitMixedContent: boolean
335
- }
336
- }
337
-
338
- interface LoadBalanceConfig {
339
- enabled: boolean
340
- balanceInterval: number
341
- channelFilterMode: 'blacklist' | 'whitelist'
342
- channelBlacklist: string[]
343
- channelWhitelist: string[]
344
- priorityChannels: string[]
345
- defaultMaxLoad: number
346
- botMaxLoad: Record<string, number>
347
- unassignedValue: string
348
- channelBotPriority: Record<string, string[]>
349
- }
350
-
146
+
147
+ <div class="section-divider"></div>
148
+
149
+ <div class="panel-header compact">
150
+ <div>
151
+ <h2>运行参数</h2>
152
+ <p>影响连接超时、重试频率与混合内容拆分</p>
153
+ </div>
154
+ </div>
155
+
156
+ <div class="runtime-grid">
157
+ <label class="field-label">超时时间 (毫秒)
158
+ <input v-model.number="runtime.responseTimeout" name="responseTimeout" autocomplete="off" type="number" inputmode="numeric" class="field-input">
159
+ </label>
160
+ <label class="field-label">心跳间隔 (毫秒)
161
+ <input v-model.number="runtime.heartbeatInterval" name="heartbeatInterval" autocomplete="off" type="number" inputmode="numeric" class="field-input">
162
+ </label>
163
+ <label class="field-label">重试次数
164
+ <input v-model.number="runtime.retryTimes" name="retryTimes" autocomplete="off" type="number" inputmode="numeric" class="field-input">
165
+ </label>
166
+ <label class="field-label">重试间隔 (毫秒)
167
+ <input v-model.number="runtime.retryInterval" name="retryInterval" autocomplete="off" type="number" inputmode="numeric" class="field-input">
168
+ </label>
169
+ <label class="field-label">懒重试 (毫秒)
170
+ <input v-model.number="runtime.retryLazy" name="retryLazy" autocomplete="off" type="number" inputmode="numeric" class="field-input">
171
+ </label>
172
+ <label class="checkbox-card">
173
+ <input v-model="runtime.advanced.splitMixedContent" name="splitMixedContent" type="checkbox">
174
+ <span>拆分混合内容</span>
175
+ </label>
176
+ </div>
177
+
178
+ <div class="footer-actions">
179
+ <button type="button" class="action-button primary" @click="saveRuntime">保存运行参数</button>
180
+ </div>
181
+ </section>
182
+
183
+ <section v-show="activeTab === 'balance'" class="panel-card">
184
+ <div class="panel-header">
185
+ <div>
186
+ <h2>负载均衡</h2>
187
+ <p>控制群过滤、默认上限、优先群与专属 Bot 规则</p>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="balance-grid">
192
+ <label class="checkbox-card">
193
+ <input v-model="loadBalance.enabled" name="lbEnabled" type="checkbox">
194
+ <span>启用负载均衡</span>
195
+ </label>
196
+ <label class="field-label">均衡间隔 (秒)
197
+ <input v-model.number="loadBalance.balanceInterval" name="balanceInterval" autocomplete="off" type="number" inputmode="numeric" class="field-input">
198
+ </label>
199
+ <label class="field-label">过滤模式
200
+ <select v-model="loadBalance.channelFilterMode" name="channelFilterMode" class="field-input field-select">
201
+ <option value="blacklist">黑名单</option>
202
+ <option value="whitelist">白名单</option>
203
+ </select>
204
+ </label>
205
+ <label class="field-label">默认最大负载 (0=无限)
206
+ <input v-model.number="loadBalance.defaultMaxLoad" name="defaultMaxLoad" autocomplete="off" type="number" inputmode="numeric" class="field-input">
207
+ </label>
208
+ <label class="field-label">未分配值
209
+ <input v-model="loadBalance.unassignedValue" name="unassignedValue" autocomplete="off" type="text" placeholder="例如 0…" class="field-input">
210
+ </label>
211
+ </div>
212
+ <div class="summary-grid">
213
+ <div class="summary-card">
214
+ <div>
215
+ <strong>群黑名单</strong>
216
+ <p>已配置 {{ countLines(blacklistText) }} 个群</p>
217
+ </div>
218
+ <button type="button" class="mini-button" @click="openListEditor('blacklist')">编辑</button>
219
+ </div>
220
+ <div class="summary-card">
221
+ <div>
222
+ <strong>群白名单</strong>
223
+ <p>已配置 {{ countLines(whitelistText) }} 个群</p>
224
+ </div>
225
+ <button type="button" class="mini-button" @click="openListEditor('whitelist')">编辑</button>
226
+ </div>
227
+ <div class="summary-card">
228
+ <div>
229
+ <strong>优先群聊</strong>
230
+ <p>已配置 {{ countLines(priorityText) }} 个群</p>
231
+ </div>
232
+ <button type="button" class="mini-button" @click="openListEditor('priority')">编辑</button>
233
+ </div>
234
+ </div>
235
+
236
+ <div class="config-grid">
237
+ <section class="sub-panel">
238
+ <div class="sub-panel-header">
239
+ <h3>Bot 最大负载</h3>
240
+ <button type="button" class="mini-button" @click="addBotMaxRow">添加行</button>
241
+ </div>
242
+ <div class="sub-panel-body">
243
+ <div v-if="botMaxLoadRows.length === 0" class="empty-row">暂无规则</div>
244
+ <div v-for="(row, idx) in botMaxLoadRows" :key="`max-${idx}`" class="row-editor">
245
+ <select v-model="row.botId" class="field-input field-select compact-input">
246
+ <option disabled value="">选择 Bot</option>
247
+ <option v-for="bot in selectableBots" :key="bot.selfId" :value="bot.selfId">
248
+ {{ bot.name || bot.nickname || bot.selfId }}
249
+ </option>
250
+ </select>
251
+ <input v-model.number="row.maxLoad" type="number" inputmode="numeric" class="field-input compact-input" placeholder="最大值">
252
+ <button type="button" class="mini-button danger" aria-label="删除负载规则" @click="removeBotMaxRow(idx)">删除</button>
253
+ </div>
254
+ </div>
255
+ </section>
256
+
257
+ <section class="sub-panel sub-panel-wide">
258
+ <div class="sub-panel-header">
259
+ <div>
260
+ <h3>群聊专属 Bot</h3>
261
+ <p>为指定群配置优先使用的 Bot 列表</p>
262
+ </div>
263
+ <button type="button" class="mini-button" @click="addChannelPriorityRow">添加行</button>
264
+ </div>
265
+ <div class="sub-panel-body">
266
+ <div v-if="channelPriorityRows.length === 0" class="empty-row">暂无规则</div>
267
+ <div v-for="(row, idx) in channelPriorityRows" :key="`priority-${idx}`" class="priority-editor">
268
+ <div class="priority-top">
269
+ <input v-model="row.channelId" type="text" inputmode="numeric" class="field-input" placeholder="群号…">
270
+ <button type="button" class="mini-button danger" aria-label="删除群聊专属 Bot 规则" @click="removeChannelPriorityRow(idx)">删除</button>
271
+ </div>
272
+ <div class="bot-choice-list">
273
+ <label v-for="bot in selectableBots" :key="`${row.channelId}-${bot.selfId}`" class="choice-chip">
274
+ <input type="checkbox" :value="bot.selfId" v-model="row.botIds">
275
+ <span>{{ bot.name || bot.nickname || bot.selfId }}</span>
276
+ </label>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </section>
281
+ </div>
282
+
283
+ <div class="footer-actions">
284
+ <button type="button" class="action-button primary" @click="saveLoadBalance">保存负载均衡规则</button>
285
+ </div>
286
+ </section>
287
+
288
+ <BotEditorModal
289
+ v-if="editing"
290
+ :model-value="editing"
291
+ :mode="editingMode"
292
+ :kind="createKind"
293
+ @close="editing = null"
294
+ @save="saveBot"
295
+ />
296
+
297
+ <div v-if="profileTarget" class="modal-overlay" @click.self="profileTarget = null">
298
+ <section class="simple-modal" aria-label="修改昵称">
299
+ <header class="simple-modal-header">
300
+ <h3>修改昵称</h3>
301
+ <button type="button" class="modal-close" aria-label="关闭昵称弹窗" @click="profileTarget = null">×</button>
302
+ </header>
303
+ <p class="modal-note" translate="no">{{ profileTarget.selfId }}</p>
304
+ <label class="field-label">昵称
305
+ <input v-model="profileNickname" name="profileNickname" autocomplete="off" type="text" class="field-input" placeholder="输入新的昵称…">
306
+ </label>
307
+ <div class="footer-actions compact-actions">
308
+ <button type="button" class="action-button primary" @click="saveProfile">保存</button>
309
+ <button type="button" class="action-button" @click="profileTarget = null">取消</button>
310
+ </div>
311
+ </section>
312
+ </div>
313
+
314
+ <div v-if="avatarTarget" class="modal-overlay" @click.self="avatarTarget = null">
315
+ <section class="simple-modal" aria-label="修改头像">
316
+ <header class="simple-modal-header">
317
+ <h3>修改头像</h3>
318
+ <button type="button" class="modal-close" aria-label="关闭头像弹窗" @click="avatarTarget = null">×</button>
319
+ </header>
320
+ <p class="modal-note" translate="no">{{ avatarTarget.selfId }}</p>
321
+ <label class="field-label">选择图片文件
322
+ <input class="file-input" type="file" accept="image/*" @change="onAvatarFileChange">
323
+ </label>
324
+ <div class="footer-actions compact-actions">
325
+ <button type="button" class="action-button primary" @click="saveAvatar">保存</button>
326
+ <button type="button" class="action-button" @click="avatarTarget = null">取消</button>
327
+ </div>
328
+ </section>
329
+ </div>
330
+
331
+ <div v-if="listEditorTarget" class="modal-overlay" @click.self="listEditorTarget = null">
332
+ <section class="simple-modal" aria-label="编辑群号列表">
333
+ <header class="simple-modal-header">
334
+ <h3>{{ listEditorTitle }}</h3>
335
+ <button type="button" class="modal-close" aria-label="关闭群号编辑弹窗" @click="listEditorTarget = null">×</button>
336
+ </header>
337
+ <p class="modal-note">每行输入一个群号,保存时会自动过滤非数字并去重。</p>
338
+ <label class="field-label">群号列表
339
+ <textarea v-model="listEditorText" rows="10" class="field-input textarea-input" placeholder="例如:123456789…"></textarea>
340
+ </label>
341
+ <div class="footer-actions compact-actions">
342
+ <button type="button" class="action-button primary" @click="saveListEditor">确认保存</button>
343
+ <button type="button" class="action-button" @click="listEditorTarget = null">取消</button>
344
+ </div>
345
+ </section>
346
+ </div>
347
+
348
+ <div v-if="toast.show" class="toast" :class="toast.type" aria-live="polite">
349
+ {{ toast.message }}
350
+ </div>
351
+ </main>
352
+ </div>
353
+ </k-layout>
354
+ </template>
355
+
356
+ <script setup lang="ts">
357
+ import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
358
+ import { send } from '@koishijs/client'
359
+ import BotEditorModal from './components/BotEditorModal.vue'
360
+
361
+ type BotStatus = 'online' | 'offline' | 'connecting'
362
+ interface ManagedBot { selfId?: string; token?: string; protocol?: 'ws' | 'ws-reverse'; endpoint?: string; path?: string; name?: string; enabled?: boolean; status?: BotStatus; nickname?: string; avatarUrl?: string; groupCount?: number; friendCount?: number; messageReceived?: number; messageSent?: number }
363
+ interface RuntimeConfig { responseTimeout: number; heartbeatInterval: number; retryTimes: number; retryInterval: number; retryLazy: number; advanced: { splitMixedContent: boolean } }
364
+ interface LoadBalanceConfig { enabled: boolean; balanceInterval: number; channelFilterMode: 'blacklist' | 'whitelist'; channelBlacklist: string[]; channelWhitelist: string[]; priorityChannels: string[]; defaultMaxLoad: number; botMaxLoad: Record<string, number>; unassignedValue: string; channelBotPriority: Record<string, string[]> }
365
+ interface ReverseServerGroup { path: string; template?: ManagedBot; bots: ManagedBot[]; onlineCount: number }
351
366
  const loading = ref(true)
352
- const activeTab = ref<'bots' | 'balance'>('bots')
353
- const bots = ref<ManagedBot[]>([])
354
-
355
- const runtime = reactive<RuntimeConfig>({
356
- responseTimeout: 60000,
357
- heartbeatInterval: 30000,
358
- retryTimes: 6,
359
- retryInterval: 5000,
360
- retryLazy: 60000,
361
- advanced: {
362
- splitMixedContent: true,
363
- },
364
- })
365
-
366
- const loadBalance = reactive<LoadBalanceConfig>({
367
- enabled: false,
368
- balanceInterval: 600,
369
- channelFilterMode: 'blacklist',
370
- channelBlacklist: [],
371
- channelWhitelist: [],
372
- priorityChannels: [],
373
- defaultMaxLoad: 0,
374
- botMaxLoad: {},
375
- unassignedValue: '',
376
- channelBotPriority: {},
377
- })
378
-
379
- const blacklistText = ref('')
380
- const whitelistText = ref('')
381
- const priorityText = ref('')
382
- const botMaxLoadRows = ref<Array<{ botId: string, maxLoad: number }>>([])
383
- const channelPriorityRows = ref<Array<{ channelId: string, botIds: string[] }>>([])
384
-
385
- const listEditorTarget = ref<'blacklist' | 'whitelist' | 'priority' | null>(null)
386
- const listEditorText = ref('')
387
-
388
- const editingMode = ref<'create' | 'edit'>('create')
389
- const editingOldSelfId = ref('')
390
- const editing = ref<ManagedBot | null>(null)
391
-
392
- const profileTarget = ref<ManagedBot | null>(null)
393
- const profileNickname = ref('')
394
-
395
- const avatarTarget = ref<ManagedBot | null>(null)
396
- const avatarFile = ref('')
397
-
398
- const toast = reactive({
399
- show: false,
400
- type: 'success' as 'success' | 'error',
401
- message: '',
402
- })
403
-
367
+ const activeTab = ref<'bots' | 'balance'>('bots')
368
+ const bots = ref<ManagedBot[]>([])
369
+ const runtime = reactive<RuntimeConfig>({ responseTimeout: 60000, heartbeatInterval: 30000, retryTimes: 6, retryInterval: 5000, retryLazy: 60000, advanced: { splitMixedContent: true } })
370
+ const loadBalance = reactive<LoadBalanceConfig>({ enabled: false, balanceInterval: 600, channelFilterMode: 'blacklist', channelBlacklist: [], channelWhitelist: [], priorityChannels: [], defaultMaxLoad: 0, botMaxLoad: {}, unassignedValue: '', channelBotPriority: {} })
371
+ const blacklistText = ref('')
372
+ const whitelistText = ref('')
373
+ const priorityText = ref('')
374
+ const botMaxLoadRows = ref<Array<{ botId: string, maxLoad: number }>>([])
375
+ const channelPriorityRows = ref<Array<{ channelId: string, botIds: string[] }>>([])
376
+ const listEditorTarget = ref<'blacklist' | 'whitelist' | 'priority' | null>(null)
377
+ const listEditorText = ref('')
378
+ const editingMode = ref<'create' | 'edit'>('create')
379
+ const createKind = ref<'client' | 'server'>('server')
380
+ const editingOldSelfId = ref('')
381
+ const editing = ref<ManagedBot | null>(null)
382
+ const profileTarget = ref<ManagedBot | null>(null)
383
+ const profileNickname = ref('')
384
+ const avatarTarget = ref<ManagedBot | null>(null)
385
+ const avatarFile = ref('')
386
+ const toast = reactive({ show: false, type: 'success' as 'success' | 'error', message: '' })
404
387
  let refreshTimer: ReturnType<typeof setInterval> | null = null
405
388
  let refreshing = false
406
-
407
- function lineArray(value: string) {
408
- return value
409
- .split('\n')
410
- .map(v => v.trim())
411
- .filter(Boolean)
412
- }
413
-
414
- function countLines(text: string) {
415
- return lineArray(text).length
416
- }
417
-
418
- const listEditorTitle = computed(() => {
419
- if (listEditorTarget.value === 'blacklist') return '编辑群黑名单'
420
- if (listEditorTarget.value === 'whitelist') return '编辑群白名单'
421
- if (listEditorTarget.value === 'priority') return '编辑优先群聊'
422
- return '编辑列表'
423
- })
424
-
425
- function openListEditor(target: 'blacklist' | 'whitelist' | 'priority') {
426
- listEditorTarget.value = target
427
- if (target === 'blacklist') listEditorText.value = blacklistText.value
428
- else if (target === 'whitelist') listEditorText.value = whitelistText.value
429
- else if (target === 'priority') listEditorText.value = priorityText.value
430
- }
431
-
432
- function saveListEditor() {
433
- const parsed = listEditorText.value
434
- .split('\n')
435
- .map(v => v.trim())
436
- .filter(v => /^\d+$/.test(v))
437
-
438
- const filtered = Array.from(new Set(parsed)).join('\n')
439
-
440
- if (listEditorTarget.value === 'blacklist') blacklistText.value = filtered
441
- else if (listEditorTarget.value === 'whitelist') whitelistText.value = filtered
442
- else if (listEditorTarget.value === 'priority') priorityText.value = filtered
443
-
444
- listEditorTarget.value = null
445
- showToast('success', '列表已暂存,请点击保存规则生效')
446
- }
447
-
448
- function showToast(type: 'success' | 'error', message: string) {
449
- toast.type = type
450
- toast.message = message
451
- toast.show = true
452
- setTimeout(() => { toast.show = false }, 2000)
453
- }
454
-
455
- function statusText(status?: BotStatus) {
456
- if (status === 'online') return '在线'
457
- if (status === 'connecting') return '连接中'
458
- return '离线'
459
- }
460
-
461
- function applyLoadBalanceText() {
462
- blacklistText.value = loadBalance.channelBlacklist.join('\n')
463
- whitelistText.value = loadBalance.channelWhitelist.join('\n')
464
- priorityText.value = loadBalance.priorityChannels.join('\n')
465
-
466
- botMaxLoadRows.value = Object.entries(loadBalance.botMaxLoad || {}).map(([botId, maxLoad]) => ({
467
- botId,
468
- maxLoad: Number(maxLoad) || 0,
469
- }))
470
-
471
- channelPriorityRows.value = Object.entries(loadBalance.channelBotPriority || {}).map(([channelId, botIds]) => ({
472
- channelId,
473
- botIds: Array.isArray(botIds) ? [...botIds] : [],
474
- }))
475
- }
476
-
477
- function addBotMaxRow() {
478
- botMaxLoadRows.value.push({ botId: '', maxLoad: 0 })
479
- }
480
-
481
- function removeBotMaxRow(index: number) {
482
- botMaxLoadRows.value.splice(index, 1)
483
- }
484
-
485
- function addChannelPriorityRow() {
486
- channelPriorityRows.value.push({ channelId: '', botIds: [] })
487
- }
488
-
489
- function removeChannelPriorityRow(index: number) {
490
- channelPriorityRows.value.splice(index, 1)
491
- }
492
-
493
- async function refreshAll() {
494
- if (refreshing) return
495
- refreshing = true
496
- try {
497
- const state = await (send as any)('onebot-multi/state')
498
- bots.value = state?.bots || []
499
- Object.assign(runtime, state?.runtime || {})
500
- Object.assign(loadBalance, state?.loadBalance || {})
501
- applyLoadBalanceText()
502
- } catch (error: any) {
503
- showToast('error', error?.message || '刷新失败')
504
- } finally {
505
- refreshing = false
506
- }
507
- }
508
-
509
- function openCreate() {
510
- editingMode.value = 'create'
511
- editingOldSelfId.value = ''
512
- editing.value = {
513
- token: '',
514
- protocol: 'ws-reverse',
515
- endpoint: '',
516
- path: '/onebot',
517
- name: '',
518
- enabled: true,
519
- }
520
- }
521
-
522
- function openEdit(bot: ManagedBot) {
523
- editingMode.value = 'edit'
524
- editingOldSelfId.value = bot.selfId
525
- editing.value = {
526
- ...bot,
527
- }
528
- }
529
-
530
- async function saveBot() {
531
- if (!editing.value) return
532
- try {
533
- if (editingMode.value === 'create') {
534
- await (send as any)('onebot-multi/bots/create', editing.value)
535
- } else {
536
- await (send as any)('onebot-multi/bots/update', {
537
- oldSelfId: editingOldSelfId.value,
538
- bot: editing.value,
539
- })
540
- }
541
-
542
- editing.value = null
543
- await refreshAll()
544
- showToast('success', '保存成功')
545
- } catch (error: any) {
546
- showToast('error', error?.message || '保存失败')
547
- }
548
- }
549
-
550
- async function removeBot(bot: ManagedBot) {
551
- if (!confirm(`确认删除 Bot ${bot.selfId} 吗?`)) return
552
- try {
553
- await (send as any)('onebot-multi/bots/delete', { selfId: bot.selfId })
554
- await refreshAll()
555
- showToast('success', '删除成功')
556
- } catch (error: any) {
557
- showToast('error', error?.message || '删除失败')
558
- }
559
- }
560
-
561
- async function toggleBot(bot: ManagedBot) {
562
- try {
563
- await (send as any)('onebot-multi/bots/toggle', { selfId: bot.selfId })
564
- await refreshAll()
565
- showToast('success', '状态已更新')
566
- } catch (error: any) {
567
- showToast('error', error?.message || '更新失败')
568
- }
569
- }
570
-
571
- async function restartBot(bot: ManagedBot) {
572
- try {
573
- await (send as any)('onebot-multi/bots/restart', { selfId: bot.selfId })
574
- await refreshAll()
575
- showToast('success', '重启已触发')
576
- } catch (error: any) {
577
- showToast('error', error?.message || '重启失败')
578
- }
579
- }
580
-
581
- function openProfile(bot: ManagedBot) {
582
- profileTarget.value = bot
583
- profileNickname.value = bot.nickname || ''
584
- }
585
-
586
- async function saveProfile() {
587
- if (!profileTarget.value) return
588
- try {
589
- const result = await (send as any)('onebot-multi/bots/profile', {
590
- selfId: profileTarget.value.selfId,
591
- nickname: profileNickname.value,
592
- })
593
- if (!result?.success) throw new Error(result?.error || '保存失败')
594
- profileTarget.value = null
595
- await refreshAll()
596
- showToast('success', '昵称已更新')
597
- } catch (error: any) {
598
- showToast('error', error?.message || '更新失败')
599
- }
600
- }
601
-
602
- function openAvatar(bot: ManagedBot) {
603
- avatarTarget.value = bot
604
- avatarFile.value = ''
605
- }
606
-
607
- function onAvatarFileChange(e: Event) {
608
- const target = e.target as HTMLInputElement
609
- const file = target.files?.[0]
610
- if (!file) {
611
- avatarFile.value = ''
612
- return
613
- }
614
- const reader = new FileReader()
615
- reader.onload = (ev) => {
616
- avatarFile.value = ev.target?.result as string
617
- }
618
- reader.readAsDataURL(file)
619
- }
620
-
621
- async function saveAvatar() {
622
- if (!avatarTarget.value || !avatarFile.value.trim()) {
623
- showToast('error', '请先选择图片文件')
624
- return
625
- }
626
- try {
627
- const result = await (send as any)('onebot-multi/bots/avatar', {
628
- selfId: avatarTarget.value.selfId,
629
- file: avatarFile.value,
630
- })
631
- if (!result?.success) throw new Error(result?.error || '保存失败')
632
- avatarTarget.value = null
633
- showToast('success', '头像已更新')
634
- } catch (error: any) {
635
- showToast('error', error?.message || '更新失败')
389
+ const listEditorTitle = computed(() => listEditorTarget.value === 'blacklist' ? '编辑群黑名单' : listEditorTarget.value === 'whitelist' ? '编辑群白名单' : listEditorTarget.value === 'priority' ? '编辑优先群聊' : '编辑列表')
390
+ const isReverseTemplateBot = (bot: ManagedBot) => bot.protocol === 'ws-reverse' && String(bot.selfId || '').startsWith('pending_')
391
+ const selectableBots = computed(() => bots.value.filter(bot => !isReverseTemplateBot(bot)))
392
+ const wsClients = computed(() => bots.value.filter(bot => bot.protocol === 'ws'))
393
+ const reverseServerGroups = computed<ReverseServerGroup[]>(() => {
394
+ const templates = bots.value.filter(isReverseTemplateBot)
395
+ const reverseBots = bots.value.filter(bot => bot.protocol === 'ws-reverse' && !isReverseTemplateBot(bot))
396
+ const groupMap = new Map<string, ReverseServerGroup>()
397
+
398
+ for (const template of templates) {
399
+ const path = template.path || '/onebot'
400
+ groupMap.set(path, { path, template, bots: [], onlineCount: 0 })
636
401
  }
637
- }
638
402
 
639
- async function saveRuntime() {
640
- try {
641
- await (send as any)('onebot-multi/settings/runtime/update', {
642
- ...runtime,
643
- })
644
- await refreshAll()
645
- showToast('success', '运行时设置已保存')
646
- } catch (error: any) {
647
- showToast('error', error?.message || '保存失败')
648
- }
649
- }
650
-
651
- async function saveLoadBalance() {
652
- try {
653
- const botMaxLoad: Record<string, number> = {}
654
- for (const row of botMaxLoadRows.value) {
655
- const botId = row.botId.trim()
656
- if (!botId) continue
657
- const maxLoad = Math.max(0, Number(row.maxLoad || 0))
658
- botMaxLoad[botId] = maxLoad
403
+ for (const bot of reverseBots) {
404
+ const path = bot.path || '/onebot'
405
+ if (!groupMap.has(path)) {
406
+ groupMap.set(path, { path, bots: [], onlineCount: 0 })
659
407
  }
660
-
661
- const channelBotPriority: Record<string, string[]> = {}
662
- for (const row of channelPriorityRows.value) {
663
- const channelId = row.channelId.trim()
664
- if (!channelId) continue
665
- const botIds = (row.botIds || []).filter(Boolean)
666
- if (botIds.length > 0) {
667
- channelBotPriority[channelId] = botIds
668
- }
669
- }
670
-
671
- const payload = {
672
- ...loadBalance,
673
- channelBlacklist: lineArray(blacklistText.value),
674
- channelWhitelist: lineArray(whitelistText.value),
675
- priorityChannels: lineArray(priorityText.value),
676
- botMaxLoad,
677
- channelBotPriority,
678
- }
679
-
680
- await (send as any)('onebot-multi/settings/load-balance/update', payload)
681
- await refreshAll()
682
- showToast('success', '负载均衡设置已保存')
683
- } catch (error: any) {
684
- showToast('error', error?.message || '保存失败')
408
+ const group = groupMap.get(path)!
409
+ group.bots.push(bot)
410
+ if (bot.status === 'online') group.onlineCount += 1
685
411
  }
686
- }
687
-
688
- onMounted(async () => {
689
- await refreshAll()
690
- loading.value = false
691
- refreshTimer = setInterval(() => {
692
- refreshAll()
693
- }, 5000)
694
- })
695
412
 
696
- onUnmounted(() => {
697
- if (refreshTimer) {
698
- clearInterval(refreshTimer)
699
- refreshTimer = null
700
- }
413
+ return Array.from(groupMap.values()).sort((a, b) => a.path.localeCompare(b.path))
701
414
  })
702
- </script>
703
-
704
- <style scoped lang="scss">
705
- :deep(.k-layout),
706
- :deep(.k-layout__main) {
707
- background: transparent !important;
708
- padding: 0 !important;
709
- margin: 0 !important;
710
- height: 100%;
711
- position: relative;
712
- }
713
-
714
- .pop-wrapper {
715
- /* Core Palette */
716
- --pop-bg: #fffbeb; /* Soft Beige/Warm Yellow */
717
- --pop-text: #6b5243; /* Soft Brown */
718
- --pop-border: 3px solid var(--pop-text);
719
- --pop-shadow-color: var(--pop-text);
720
- --pop-panel: #ffffff;
721
- --pop-primary: #ff9a9e; /* Soft Pink */
722
- --pop-secondary: #a1c4fd; /* Soft Blue */
723
- --pop-success: #b2fba5; /* Soft Green */
724
- --pop-danger: #ffb3ba; /* Soft Red */
725
-
726
- /* Dots for halftone */
727
- --pop-dot: rgba(107, 82, 67, 0.05); /* very subtle */
728
-
729
- width: 100%;
730
- height: 100%;
731
- box-sizing: border-box;
732
- color: var(--pop-text);
733
- font-family: 'Nunito', 'Varela Round', 'PingFang SC', 'Microsoft YaHei', sans-serif;
734
-
735
- background-color: var(--pop-bg);
736
- background-image:
737
- radial-gradient(var(--pop-dot) 15%, transparent 16%),
738
- radial-gradient(var(--pop-dot) 15%, transparent 16%);
739
- background-size: 24px 24px;
740
- background-position: 0 0, 12px 12px;
741
-
742
- position: absolute;
743
- top: 0;
744
- left: 0;
745
- right: 0;
746
- bottom: 0;
747
- z-index: 0;
748
- overflow-y: auto;
749
- padding: 24px;
750
-
751
- /* Bold, uppercase emphasis */
752
- font-weight: 800;
753
- }
754
-
755
- .pop-wrapper * {
756
- box-sizing: border-box;
757
- }
758
-
759
- /* Base Comic Style */
760
- .comic-panel {
761
- background: var(--pop-panel);
762
- border: var(--pop-border);
763
- box-shadow: 4px 4px 0 var(--pop-shadow-color);
764
- border-radius: 16px;
765
- }
766
-
767
- /* Loading */
768
- .pop-loading {
769
- display: flex;
770
- justify-content: center;
771
- align-items: center;
772
- height: 60vh;
773
- }
774
- .comic-text {
775
- font-size: 4rem;
776
- color: #fff;
777
- text-shadow: 2px 2px 0 var(--pop-text), -1px -1px 0 var(--pop-text), 1px -1px 0 var(--pop-text), -1px 1px 0 var(--pop-text), 1px 1px 0 var(--pop-text);
778
- transform: rotate(-2deg);
779
- animation: pulse 1s infinite alternate;
780
- }
781
-
782
- @keyframes pulse {
783
- 0% { transform: rotate(-5deg) scale(1); }
784
- 100% { transform: rotate(-5deg) scale(1.1); }
785
- }
786
-
787
- .pop-container {
788
- max-width: 1200px;
789
- margin: 0 auto;
790
- display: flex;
791
- flex-direction: column;
792
- gap: 24px;
793
- }
794
-
795
- /* HEADER */
796
- .pop-header {
797
- display: flex;
798
- justify-content: space-between;
799
- align-items: center;
800
- gap: 16px;
801
- flex-wrap: wrap;
802
- background: var(--pop-secondary);
803
- border: var(--pop-border);
804
- box-shadow: 4px 4px 0 var(--pop-shadow-color);
805
- padding: 20px 24px;
806
- border-radius: 16px;
807
- position: relative;
808
- overflow: hidden;
809
- }
810
- .pop-header::before {
811
- content: '';
812
- position: absolute;
813
- top: -50%; left: -50%; width: 200%; height: 200%;
814
- background-image: repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,0.2) 10px, rgba(255,255,255,0.2) 20px);
815
- z-index: 0;
816
- }
817
- .pop-header > * { position: relative; z-index: 1; }
818
-
819
- .brand { display: flex; align-items: center; gap: 16px; }
820
- .logo {
821
- font-size: 42px; background: var(--pop-bg); width: 72px; height: 72px;
822
- border-radius: 50%; border: var(--pop-border);
823
- display: flex; align-items: center; justify-content: center;
824
- box-shadow: 4px 4px 0 var(--pop-shadow-color); transform: rotate(-5deg);
825
- }
826
-
827
- .title-box h1 {
828
- margin: 0; font-size: 36px; font-weight: 900; color: #fff;
829
- text-shadow: 2px 2px 0 var(--pop-text), -1px -1px 0 var(--pop-text), 1px -1px 0 var(--pop-text), -1px 1px 0 var(--pop-text), 1px 1px 0 var(--pop-text);
830
- letter-spacing: 2px; text-transform: uppercase;
831
- }
832
- .title-box p {
833
- margin: 4px 0 0; background: var(--pop-text); color: #fff; display: inline-block;
834
- padding: 4px 8px; font-size: 14px; transform: skewX(-5deg); text-transform: uppercase;
835
- }
836
-
837
- .pop-tabs { display: flex; gap: 12px; flex-wrap: wrap; }
838
-
839
- /* BUTTONS */
840
- .pop-btn {
841
- background: #fff; color: var(--pop-text); border: var(--pop-border);
842
- box-shadow: 4px 4px 0 var(--pop-shadow-color); padding: 10px 20px;
843
- font-size: 16px; font-family: inherit; font-weight: 900; cursor: pointer;
844
- border-radius: 16px; transition: all 0.1s ease; position: relative; text-transform: uppercase;
845
- }
846
- .pop-btn:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--pop-shadow-color); }
847
- .pop-btn:active { transform: translate(4px, 4px); box-shadow: 0px 0px 0 var(--pop-shadow-color); }
848
- .pop-btn.active { background: var(--pop-primary); color: #fff; }
849
- .pop-btn.primary-btn { background: var(--pop-primary); color: #fff; font-size: 18px; }
850
- .pop-btn.action-btn { background: #ffb347; color: #fff; }
851
-
852
- .pop-btn.mini { padding: 6px 12px; font-size: 13px; box-shadow: 2px 2px 0 var(--pop-shadow-color); border-width: 2px; }
853
- .pop-btn.mini:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--pop-shadow-color); }
854
- .pop-btn.mini:active { transform: translate(3px, 3px); box-shadow: 0px 0px 0 var(--pop-shadow-color); }
855
- .pop-btn.danger { background: var(--pop-danger); color: #fff; }
856
-
857
- /* PANELS */
858
- .pop-panel {
859
- background: var(--pop-panel); border: var(--pop-border); box-shadow: 4px 4px 0 var(--pop-shadow-color);
860
- border-radius: 16px; padding: 24px; position: relative;
861
- }
862
- .panel-header {
863
- display: flex; justify-content: space-between; align-items: flex-start;
864
- margin-bottom: 24px; border-bottom: 3px solid var(--pop-text); padding-bottom: 16px;
865
- }
866
- .panel-title h2 { margin: 0; font-size: 28px; color: var(--pop-primary); text-shadow: 2px 2px 0 var(--pop-text); text-transform: uppercase; }
867
- .panel-title p { margin: 4px 0 0; font-size: 16px; font-weight: bold; text-transform: uppercase; }
868
-
869
- /* BOT GRID */
870
- .bot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
871
- .bot-card {
872
- border: var(--pop-border); border-radius: 16px; background: #fff; padding: 16px;
873
- box-shadow: 4px 4px 0 var(--pop-shadow-color); transition: transform 0.2s, box-shadow 0.2s;
874
- position: relative; overflow: hidden;
875
- }
876
- .bot-card::before {
877
- content: ''; position: absolute; top: 0; right: 0; width: 40px; height: 40px;
878
- background: var(--pop-bg); border-bottom-left-radius: 20px; border-left: var(--pop-border); border-bottom: var(--pop-border);
879
- }
880
- .bot-card.disabled { background: #e5e7eb; opacity: 0.8; }
881
- .bot-card.disabled .bot-avatar { filter: grayscale(1); }
882
- .bot-card:hover { transform: translate(-4px, -4px); box-shadow: 6px 6px 0 var(--pop-shadow-color); }
883
-
884
- .bot-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
885
- .bot-avatar {
886
- width: 64px; height: 64px; border-radius: 50%; border: var(--pop-border);
887
- background: var(--pop-bg); object-fit: cover; box-shadow: 2px 2px 0 var(--pop-text);
888
- }
889
- .bot-info { flex: 1; overflow: hidden; }
890
- .bot-name { font-size: 20px; font-weight: 900; margin-bottom: 4px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; text-transform: uppercase; }
891
- .bot-sub {
892
- font-size: 13px; color: var(--pop-text); background: var(--pop-bg);
893
- display: inline-block; padding: 2px 6px; border: 2px solid var(--pop-text); border-radius: 4px; margin-bottom: 4px;
894
- }
895
- .bot-id { font-family: 'Courier New', Courier, monospace; font-size: 12px; font-weight: bold; }
896
-
897
- .status-badge {
898
- border: 3px solid var(--pop-text); border-radius: 16px; padding: 4px 10px;
899
- font-size: 14px; font-weight: 900; box-shadow: 2px 2px 0 var(--pop-text); transform: rotate(2deg);
900
- text-transform: uppercase;
901
- }
902
- .status-online { background: var(--pop-success); color: var(--pop-text); }
903
- .status-offline { background: var(--pop-danger); color: #fff; }
904
- .status-connecting { background: var(--pop-bg); color: var(--pop-text); }
905
-
906
- .bot-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 16px; }
907
- .stat-box {
908
- display: flex; justify-content: space-between; padding: 6px 10px;
909
- border: 2px solid var(--pop-text); border-radius: 10px; background: #f8f9fa; box-shadow: 2px 2px 0 var(--pop-shadow-color);
910
- }
911
- .stat-box span { color: #666; font-size: 12px; font-weight: bold; text-transform: uppercase; }
912
- .stat-box strong { font-size: 16px; }
913
-
914
- .bot-meta {
915
- background: var(--pop-secondary); border: 2px solid var(--pop-text); padding: 8px;
916
- border-radius: 10px; font-size: 12px; margin-bottom: 16px; word-break: break-all;
917
- }
918
- .bot-actions { display: flex; flex-wrap: wrap; gap: 8px; }
919
-
920
- /* FORMS */
921
- .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 24px; }
922
- .modal-grid { grid-template-columns: 1fr; gap: 12px; max-height: 50vh; overflow-y: auto; padding-right: 8px; }
923
-
924
- .pop-label { display: flex; flex-direction: column; gap: 8px; font-size: 16px; font-weight: bold; text-transform: uppercase; }
925
- .pop-label.hidden { display: none; }
926
-
927
- .pop-input, .pop-select, .pop-textarea {
928
- border: 3px solid var(--pop-text); border-radius: 16px; padding: 10px 14px;
929
- font-size: 15px; font-family: inherit; font-weight: bold; background: #fff; color: var(--pop-text);
930
- box-shadow: inset 2px 2px 0 rgba(0,0,0,0.1); transition: all 0.2s;
931
- }
932
- .pop-input:focus, .pop-select:focus, .pop-textarea:focus {
933
- outline: none; background: #fffde7; box-shadow: 4px 4px 0 var(--pop-shadow-color); transform: translate(-2px, -2px);
934
- }
935
- .pop-textarea { resize: vertical; }
936
-
937
- .pop-file-input {
938
- border: 3px solid var(--pop-text);
939
- border-radius: 16px;
940
- padding: 8px;
941
- font-size: 14px;
942
- font-family: inherit;
943
- font-weight: bold;
944
- background: #fff;
945
- color: var(--pop-text);
946
- box-shadow: inset 2px 2px 0 rgba(0,0,0,0.1);
947
- transition: all 0.2s;
948
- cursor: pointer;
949
- }
950
- .pop-file-input:focus, .pop-file-input:hover {
951
- outline: none; background: #fffde7; box-shadow: 4px 4px 0 var(--pop-shadow-color); transform: translate(-2px, -2px);
952
- }
953
- .pop-file-input::file-selector-button {
954
- background: var(--pop-primary);
955
- color: #fff;
956
- border: 2px solid var(--pop-text);
957
- border-radius: 8px;
958
- padding: 6px 12px;
959
- font-weight: bold;
960
- cursor: pointer;
961
- margin-right: 12px;
962
- text-transform: uppercase;
963
- }
964
-
965
- .pop-checkbox {
966
- flex-direction: row; align-items: center; cursor: pointer; background: var(--pop-bg);
967
- padding: 10px; border: 3px solid var(--pop-text); border-radius: 16px; box-shadow: 2px 2px 0 var(--pop-shadow-color);
968
- transition: transform 0.1s;
969
- }
970
- .pop-checkbox:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--pop-shadow-color); }
971
- .pop-check { width: 20px; height: 20px; accent-color: var(--pop-primary); border: 2px solid var(--pop-text); cursor: pointer; }
972
-
973
- .mini-checkbox { padding: 4px 8px; border-radius: 8px; font-size: 13px; gap: 4px; border-width: 2px; }
974
- .mini-check { width: 14px; height: 14px; }
975
-
976
- /* SPLIT GRID FOR BALANCE */
977
- .split-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 24px; }
978
-
979
- .list-summary-box {
980
- display: flex;
981
- align-items: center;
982
- justify-content: space-between;
983
- background: #fff;
984
- border: 3px solid var(--pop-text);
985
- border-radius: 12px;
986
- padding: 8px 12px;
987
- box-shadow: 2px 2px 0 var(--pop-shadow-color);
988
- margin-top: 4px;
989
- }
990
- .list-count {
991
- font-size: 14px;
992
- font-weight: 900;
993
- color: var(--pop-primary);
994
- }
995
-
996
- /* TABLE EDITOR */
997
- .pop-table-editor {
998
- border: 3px dashed var(--pop-text); background: var(--pop-secondary); border-radius: 16px;
999
- padding: 16px; display: flex; flex-direction: column; gap: 12px; position: relative;
1000
- }
1001
- .pop-table-editor.full-width { grid-column: 1 / -1; background: #ffb3ff; }
1002
-
1003
- .table-head {
1004
- display: flex; justify-content: space-between; align-items: center;
1005
- font-size: 18px; font-weight: 900; background: #fff; padding: 6px 12px;
1006
- border: 3px solid var(--pop-text); border-radius: 16px; box-shadow: 2px 2px 0 var(--pop-text); text-transform: uppercase;
1007
- }
1008
- .table-body { display: flex; flex-direction: column; gap: 8px; max-height: 250px; overflow-y: auto; padding-right: 8px; }
1009
- .empty-row {
1010
- background: #fff; padding: 12px; text-align: center; border: 3px solid var(--pop-text);
1011
- border-radius: 16px; font-weight: bold; text-transform: uppercase;
1012
- }
1013
- .editor-row {
1014
- display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; background: #fff;
1015
- padding: 8px; border: 3px solid var(--pop-text); border-radius: 16px; box-shadow: 2px 2px 0 var(--pop-text);
1016
- }
1017
- .editor-row.wide { grid-template-columns: 1fr 2fr auto; }
1018
- .mini-input { padding: 6px 10px; font-size: 14px; }
1019
- .panel-footer { margin-top: 24px; padding-top: 20px; border-top: 3px solid var(--pop-text); display: flex; justify-content: flex-end; }
1020
-
1021
- /* MODALS */
1022
- .pop-modal-overlay {
1023
- position: fixed; inset: 0; background: rgba(107, 82, 67, 0.4); backdrop-filter: blur(4px);
1024
- display: flex; align-items: center; justify-content: center; z-index: 1000;
1025
- }
1026
- .pop-modal { width: min(600px, 90vw); position: relative; padding: 32px; }
1027
- .pop-modal h3 { font-size: 32px; margin: 0 0 8px 0; color: var(--pop-primary); text-shadow: 2px 2px 0 var(--pop-text); text-transform: uppercase; }
1028
- .modal-sub {
1029
- font-family: 'Courier New', Courier, monospace; background: var(--pop-bg); display: inline-block;
1030
- padding: 4px 8px; border: 2px solid var(--pop-text); margin-bottom: 20px; font-weight: bold;
1031
- }
1032
- .comic-burst {
1033
- position: absolute; top: -15px; right: -15px; background: var(--pop-bg); color: var(--pop-text);
1034
- font-size: 24px; font-weight: 900; padding: 10px; border: var(--pop-border); border-radius: 50%;
1035
- transform: rotate(5deg); box-shadow: 4px 4px 0 var(--pop-shadow-color);
1036
- animation: pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); text-transform: uppercase;
1037
- }
1038
- @keyframes pop {
1039
- 0% { transform: scale(0) rotate(0deg); }
1040
- 100% { transform: scale(1) rotate(15deg); }
1041
- }
1042
- .modal-actions { display: flex; gap: 12px; margin-top: 24px; justify-content: flex-end; }
1043
-
1044
- /* TOAST */
1045
- .pop-toast {
1046
- position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
1047
- padding: 12px 24px; font-size: 20px; font-weight: 900; z-index: 1100;
1048
- display: flex; align-items: center; gap: 12px; animation: slideUp 0.3s ease-out; text-transform: uppercase;
1049
- }
1050
- .pop-toast.success { background: var(--pop-success); color: var(--pop-text); }
1051
- .pop-toast.error { background: var(--pop-danger); color: #fff; }
1052
- @keyframes slideUp { 0% { transform: translate(-50%, 100%); opacity: 0; } 100% { transform: translate(-50%, 0); opacity: 1; } }
1053
-
1054
- /* EMPTY STATE */
1055
- .empty-state { display: flex; justify-content: center; padding: 40px; }
1056
- .comic-speech {
1057
- position: relative; background: #fff; border: 3px solid var(--pop-text); border-radius: 16px;
1058
- padding: 20px 30px; font-size: 24px; font-weight: bold; box-shadow: 4px 4px 0 var(--pop-shadow-color); text-transform: uppercase;
1059
- }
1060
- .comic-speech::after {
1061
- display: none;
1062
- content: ''; position: absolute; bottom: -20px; left: 30px;
1063
- border-width: 12px 12px 0 0; border-style: solid; border-color: #fff transparent transparent transparent;
1064
- }
1065
- .comic-speech::before {
1066
- display: none;
1067
- content: ''; position: absolute; bottom: -26px; left: 28px;
1068
- border-width: 14px 14px 0 0; border-style: solid; border-color: var(--pop-text) transparent transparent transparent; z-index: -1;
1069
- }
1070
-
1071
- /* CUSTOM SCROLLBAR */
1072
- ::-webkit-scrollbar { width: 12px; }
1073
- ::-webkit-scrollbar-track { background: var(--pop-bg); border-left: 3px solid var(--pop-text); }
1074
- ::-webkit-scrollbar-thumb { background: var(--pop-primary); border: 3px solid var(--pop-text); border-radius: 0; }
1075
-
1076
- @media (max-width: 960px) {
1077
- .split-grid { grid-template-columns: 1fr; }
1078
- }
1079
-
1080
- @media (max-width: 768px) {
1081
- .pop-wrapper { padding: 12px; }
1082
- .pop-header { flex-direction: column; text-align: center; }
1083
- .pop-tabs { justify-content: center; width: 100%; }
1084
- .pop-btn { flex: 1; min-width: 45%; text-align: center; }
1085
- .bot-actions .pop-btn { flex: 1; min-width: 30%; }
1086
- .pop-modal { padding: 20px; }
1087
- .comic-speech { font-size: 18px; }
1088
- .bot-grid { grid-template-columns: 1fr; }
1089
- }
1090
- </style>
415
+ const lineArray = (value: string) => value.split('\n').map(v => v.trim()).filter(Boolean)
416
+ const countLines = (text: string) => lineArray(text).length
417
+ function openListEditor(target: 'blacklist' | 'whitelist' | 'priority') { listEditorTarget.value = target; if (target === 'blacklist') listEditorText.value = blacklistText.value; else if (target === 'whitelist') listEditorText.value = whitelistText.value; else listEditorText.value = priorityText.value }
418
+ function saveListEditor() { const parsed = listEditorText.value.split('\n').map(v => v.trim()).filter(v => /^\d+$/.test(v)); const filtered = Array.from(new Set(parsed)).join('\n'); if (listEditorTarget.value === 'blacklist') blacklistText.value = filtered; else if (listEditorTarget.value === 'whitelist') whitelistText.value = filtered; else if (listEditorTarget.value === 'priority') priorityText.value = filtered; listEditorTarget.value = null; showToast('success', '列表已暂存,请点击保存规则生效') }
419
+ function showToast(type: 'success' | 'error', message: string) { toast.type = type; toast.message = message; toast.show = true; setTimeout(() => { toast.show = false }, 2000) }
420
+ function statusText(status?: BotStatus) { return status === 'online' ? '在线' : status === 'connecting' ? '连接中' : '离线' }
421
+ function applyLoadBalanceText() { blacklistText.value = loadBalance.channelBlacklist.join('\n'); whitelistText.value = loadBalance.channelWhitelist.join('\n'); priorityText.value = loadBalance.priorityChannels.join('\n'); botMaxLoadRows.value = Object.entries(loadBalance.botMaxLoad || {}).map(([botId, maxLoad]) => ({ botId, maxLoad: Number(maxLoad) || 0 })); channelPriorityRows.value = Object.entries(loadBalance.channelBotPriority || {}).map(([channelId, botIds]) => ({ channelId, botIds: Array.isArray(botIds) ? [...botIds] : [] })) }
422
+ function addBotMaxRow() { botMaxLoadRows.value.push({ botId: '', maxLoad: 0 }) }
423
+ function removeBotMaxRow(index: number) { botMaxLoadRows.value.splice(index, 1) }
424
+ function addChannelPriorityRow() { channelPriorityRows.value.push({ channelId: '', botIds: [] }) }
425
+ function removeChannelPriorityRow(index: number) { channelPriorityRows.value.splice(index, 1) }
426
+ async function refreshAll() { if (refreshing) return; refreshing = true; try { const state = await (send as any)('onebot-multi/state'); bots.value = state?.bots || []; Object.assign(runtime, state?.runtime || {}); Object.assign(loadBalance, state?.loadBalance || {}); applyLoadBalanceText() } catch (error: any) { showToast('error', error?.message || '刷新失败') } finally { refreshing = false } }
427
+ function openCreate(kind: 'client' | 'server') { editingMode.value = 'create'; createKind.value = kind; editingOldSelfId.value = ''; editing.value = { token: '', protocol: kind === 'client' ? 'ws' : 'ws-reverse', endpoint: kind === 'client' ? '' : undefined, path: kind === 'server' ? '/onebot' : undefined, name: '', enabled: true } }
428
+ function openEdit(bot: ManagedBot) { editingMode.value = 'edit'; createKind.value = bot.protocol === 'ws' ? 'client' : 'server'; editingOldSelfId.value = bot.selfId || ''; editing.value = { ...bot } }
429
+ async function saveBot(payload: ManagedBot) { editing.value = payload; if (!editing.value) return; try { if (editingMode.value === 'create') await (send as any)('onebot-multi/bots/create', editing.value); else await (send as any)('onebot-multi/bots/update', { oldSelfId: editingOldSelfId.value, bot: editing.value }); editing.value = null; await refreshAll(); showToast('success', '保存成功') } catch (error: any) { showToast('error', error?.message || '保存失败') } }
430
+ async function removeBot(bot: ManagedBot) { if (!confirm(`确认删除 Bot ${bot.selfId} 吗?`)) return; try { await (send as any)('onebot-multi/bots/delete', { selfId: bot.selfId }); await refreshAll(); showToast('success', '删除成功') } catch (error: any) { showToast('error', error?.message || '删除失败') } }
431
+ async function toggleBot(bot: ManagedBot) { try { await (send as any)('onebot-multi/bots/toggle', { selfId: bot.selfId }); await refreshAll(); showToast('success', '状态已更新') } catch (error: any) { showToast('error', error?.message || '更新失败') } }
432
+ async function restartBot(bot: ManagedBot) { try { await (send as any)('onebot-multi/bots/restart', { selfId: bot.selfId }); await refreshAll(); showToast('success', '重启已触发') } catch (error: any) { showToast('error', error?.message || '重启失败') } }
433
+ function openProfile(bot: ManagedBot) { profileTarget.value = bot; profileNickname.value = bot.nickname || '' }
434
+ async function saveProfile() { if (!profileTarget.value) return; try { const result = await (send as any)('onebot-multi/bots/profile', { selfId: profileTarget.value.selfId, nickname: profileNickname.value }); if (!result?.success) throw new Error(result?.error || '保存失败'); profileTarget.value = null; await refreshAll(); showToast('success', '昵称已更新') } catch (error: any) { showToast('error', error?.message || '更新失败') } }
435
+ function openAvatar(bot: ManagedBot) { avatarTarget.value = bot; avatarFile.value = '' }
436
+ function onAvatarFileChange(e: Event) { const target = e.target as HTMLInputElement; const file = target.files?.[0]; if (!file) { avatarFile.value = ''; return } const reader = new FileReader(); reader.onload = (ev) => { avatarFile.value = ev.target?.result as string }; reader.readAsDataURL(file) }
437
+ async function saveAvatar() { if (!avatarTarget.value || !avatarFile.value.trim()) { showToast('error', '请先选择图片文件'); return } try { const result = await (send as any)('onebot-multi/bots/avatar', { selfId: avatarTarget.value.selfId, file: avatarFile.value }); if (!result?.success) throw new Error(result?.error || '保存失败'); avatarTarget.value = null; showToast('success', '头像已更新') } catch (error: any) { showToast('error', error?.message || '更新失败') } }
438
+ async function saveRuntime() { try { await (send as any)('onebot-multi/settings/runtime/update', { ...runtime }); await refreshAll(); showToast('success', '运行时设置已保存') } catch (error: any) { showToast('error', error?.message || '保存失败') } }
439
+ async function saveLoadBalance() { try { const botMaxLoad: Record<string, number> = {}; for (const row of botMaxLoadRows.value) { const botId = row.botId.trim(); if (!botId) continue; const maxLoad = Math.max(0, Number(row.maxLoad || 0)); botMaxLoad[botId] = maxLoad } const channelBotPriority: Record<string, string[]> = {}; for (const row of channelPriorityRows.value) { const channelId = row.channelId.trim(); if (!channelId) continue; const botIds = (row.botIds || []).filter(Boolean); if (botIds.length > 0) channelBotPriority[channelId] = botIds } const payload = { ...loadBalance, channelBlacklist: lineArray(blacklistText.value), channelWhitelist: lineArray(whitelistText.value), priorityChannels: lineArray(priorityText.value), botMaxLoad, channelBotPriority }; await (send as any)('onebot-multi/settings/load-balance/update', payload); await refreshAll(); showToast('success', '负载均衡设置已保存') } catch (error: any) { showToast('error', error?.message || '保存失败') } }
440
+ onMounted(async () => { await refreshAll(); loading.value = false; refreshTimer = setInterval(() => { refreshAll() }, 5000) })
441
+ onUnmounted(() => { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null } })
442
+ </script>
443
+
444
+ <style scoped lang="scss">
445
+ :deep(.k-layout),
446
+ :deep(.k-layout__main) { background: transparent !important; padding: 0 !important; margin: 0 !important; height: 100%; position: relative; }
447
+ .skip-link { position: absolute; left: -9999px; top: 0; }
448
+ .skip-link:focus { left: 16px; top: 16px; z-index: 1200; background: #fffdf7; color: #6b5243; border: 3px solid #6b5243; border-radius: 12px; padding: 8px 12px; }
449
+ .page-shell { position: absolute; inset: 0; width: 100%; height: 100%; box-sizing: border-box; overflow-y: auto; overflow-x: hidden; padding: 24px; background: #fffbeb; background-image: radial-gradient(rgba(107, 82, 67, 0.06) 15%, transparent 16%), radial-gradient(rgba(107, 82, 67, 0.06) 15%, transparent 16%); background-size: 24px 24px; background-position: 0 0, 12px 12px; color: #6b5243; }
450
+ .page-body { max-width: 1200px; margin: 0 auto; display: grid; gap: 24px; overflow-x: hidden; }
451
+ .loading-state { min-height: 60vh; display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 800; color: #6b5243; }
452
+ .hero-card, .panel-card { background: #fffdf7; border: 3px solid #6b5243; border-radius: 20px; overflow-x: hidden; }
453
+ .hero-card { padding: 24px; display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; flex-wrap: wrap; }
454
+ .hero-kicker { margin: 0 0 10px; display: inline-block; padding: 4px 10px; border-radius: 999px; border: 2px solid #6b5243; background: #fff; font-size: 13px; font-weight: 800; }
455
+ .hero-copy h1 { margin: 0; font-size: 36px; text-wrap: balance; }
456
+ .hero-copy p { margin: 8px 0 0; color: #8b6b57; }
457
+ .hero-actions, .create-actions, .footer-actions, .compact-actions, .card-actions { display: flex; gap: 12px; flex-wrap: wrap; }
458
+ .tab-bar { display: flex; gap: 12px; }
459
+ .tab-button, .action-button, .mini-button { border: 3px solid #6b5243; border-radius: 16px; background: #fff; color: #6b5243; font: inherit; font-weight: 800; cursor: pointer; transition: transform 0.12s ease, background-color 0.12s ease; touch-action: manipulation; }
460
+ .tab-button, .action-button { padding: 10px 18px; }
461
+ .mini-button { padding: 8px 12px; }
462
+ .tab-button:hover, .tab-button:focus-visible, .action-button:hover, .action-button:focus-visible, .mini-button:hover, .mini-button:focus-visible { transform: translate(-1px, -1px); }
463
+ .tab-button:active, .action-button:active, .mini-button:active { transform: translate(2px, 2px); }
464
+ .tab-button.active, .action-button.primary { background: #ffb870; }
465
+ .action-button.secondary { background: #ffffff; }
466
+ .mini-button.danger { background: #ffd6d9; }
467
+ .panel-card { padding: 24px; }
468
+ .panel-header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 20px; }
469
+ .panel-header.compact { margin-bottom: 16px; }
470
+ .panel-header h2, .sub-panel-header h3, .simple-modal-header h3 { margin: 0; text-wrap: balance; }
471
+ .panel-header p, .sub-panel-header p { margin: 6px 0 0; color: #8b6b57; }
472
+ .empty-card { padding: 24px; border: 2px dashed #6b5243; border-radius: 16px; background: #fff; color: #8b6b57; }
473
+ .bot-sections { display: grid; gap: 24px; }
474
+ .bot-section-block { display: grid; gap: 16px; }
475
+ .section-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
476
+ .section-title-row h3 { margin: 0; }
477
+ .section-note { color: #8b6b57; font-size: 14px; }
478
+ .server-group-list { display: grid; gap: 18px; }
479
+ .server-group-card { padding: 18px; border: 3px solid #6b5243; border-radius: 18px; background: #fff8e8; display: grid; gap: 16px; }
480
+ .server-group-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
481
+ .server-group-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
482
+ .server-group-title { font-size: 22px; font-weight: 900; }
483
+ .server-tag { border: 2px solid #6b5243; border-radius: 999px; padding: 4px 10px; background: #fffdf7; font-size: 12px; font-weight: 900; }
484
+ .server-group-subtitle { color: #8b6b57; font-size: 14px; }
485
+ .server-group-path { margin-top: 4px; font-family: 'Courier New', monospace; font-size: 13px; }
486
+ .server-group-meta { display: flex; gap: 10px; flex-wrap: wrap; }
487
+ .server-count { border: 2px solid #6b5243; border-radius: 999px; padding: 6px 10px; background: #fffdf7; font-size: 12px; font-weight: 900; }
488
+ .section-actions { margin-top: -4px; }
489
+ .inner-bot-grid { margin-top: 4px; }
490
+ .bot-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 18px; }
491
+ .bot-card { padding: 18px; border: 3px solid #6b5243; border-radius: 18px; background: #fff; }
492
+ .bot-card.disabled { opacity: 0.7; }
493
+ .bot-card-header { display: flex; gap: 14px; align-items: flex-start; }
494
+ .bot-avatar { width: 64px; height: 64px; border: 3px solid #6b5243; border-radius: 50%; object-fit: cover; background: #fff7df; flex: 0 0 auto; }
495
+ .bot-summary { min-width: 0; flex: 1; }
496
+ .bot-name { font-size: 20px; font-weight: 900; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
497
+ .bot-subtitle, .bot-meta-line { color: #8b6b57; font-size: 14px; }
498
+ .bot-id { margin-top: 4px; font-family: 'Courier New', monospace; font-size: 12px; }
499
+ .status-pill { flex: 0 0 auto; border: 2px solid #6b5243; border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 900; }
500
+ .status-online { background: #d9f6c8; }
501
+ .status-offline { background: #ffd6d9; }
502
+ .status-connecting { background: #fff4c2; }
503
+ .stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 16px 0; }
504
+ .stat-item { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 2px solid #6b5243; border-radius: 14px; background: #fffdf7; }
505
+ .stat-item span { color: #8b6b57; }
506
+ .stat-item strong { font-variant-numeric: tabular-nums; }
507
+ .detail-card { padding: 12px; border: 2px solid #6b5243; border-radius: 14px; background: #eef4ff; font-size: 14px; line-height: 1.6; word-break: break-word; margin-bottom: 16px; }
508
+ .section-divider { margin: 28px 0 20px; border-top: 3px dashed #d7c1a8; }
509
+ .runtime-grid, .balance-grid, .summary-grid, .config-grid { display: grid; gap: 16px; }
510
+ .runtime-grid, .balance-grid { grid-template-columns: 1fr; }
511
+ .runtime-grid > *, .balance-grid > *, .summary-grid > *, .config-grid > * { min-width: 0; }
512
+ .runtime-grid .field-label, .balance-grid .field-label {
513
+ box-sizing: border-box;
514
+ width: 100%;
515
+ max-width: 100%;
516
+ overflow: hidden;
517
+ display: grid;
518
+ gap: 8px;
519
+ min-width: 0;
520
+ padding: 14px 16px;
521
+ border: 3px solid #6b5243;
522
+ border-radius: 18px;
523
+ background: #fffdf7;
524
+ font-weight: 800;
525
+ color: #6b5243;
526
+ line-height: 1.4;
527
+ }
528
+ .runtime-grid .checkbox-card,
529
+ .balance-grid .checkbox-card {
530
+ overflow: hidden;
531
+ display: grid;
532
+ grid-template-columns: 20px minmax(0, 1fr);
533
+ box-sizing: border-box;
534
+ align-items: center;
535
+ gap: 12px;
536
+ width: 100%;
537
+ min-width: 0;
538
+ max-width: 100%;
539
+ padding: 14px 16px;
540
+ border: 3px solid #6b5243;
541
+ border-radius: 18px;
542
+ background: #fff7df;
543
+ font-weight: 800;
544
+ color: #6b5243;
545
+ }
546
+ .runtime-grid .checkbox-card input,
547
+ .balance-grid .checkbox-card input {
548
+ width: 20px;
549
+ height: 20px;
550
+ margin: 0;
551
+ }
552
+ .runtime-grid .checkbox-card span,
553
+ .balance-grid .checkbox-card span {
554
+ min-width: 0;
555
+ line-height: 1.4;
556
+ word-break: break-word;
557
+ }
558
+ .summary-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin: 20px 0; }
559
+ .summary-card, .sub-panel, .simple-modal { background: #fff; border: 3px solid #6b5243; border-radius: 18px; }
560
+ .summary-card { padding: 16px; display: flex; justify-content: space-between; gap: 12px; align-items: center; }
561
+ .summary-card p { margin: 6px 0 0; color: #8b6b57; font-size: 14px; }
562
+ .config-grid { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
563
+ .sub-panel { padding: 16px; }
564
+ .sub-panel-wide { grid-column: 1 / -1; }
565
+ .sub-panel-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
566
+ .sub-panel-body { display: grid; gap: 10px; }
567
+ .empty-row { padding: 14px; text-align: center; color: #8b6b57; border: 2px dashed #6b5243; border-radius: 14px; }
568
+ .row-editor, .priority-editor { display: grid; gap: 10px; padding: 12px; border: 2px solid #6b5243; border-radius: 14px; background: #fffdf7; }
569
+ .row-editor { grid-template-columns: minmax(0, 1fr) minmax(120px, 180px) auto; }
570
+ .priority-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
571
+ .bot-choice-list { display: flex; flex-wrap: wrap; gap: 10px; }
572
+ .choice-chip { display: inline-flex; align-items: center; gap: 8px; width: fit-content; max-width: 100%; min-width: 0; padding: 10px 12px; border: 2px solid #6b5243; border-radius: 14px; background: #fff7df; font-weight: 700; }
573
+ .field-input, .field-select, .row-input, .row-select {
574
+ box-sizing: border-box;
575
+ display: block;
576
+ width: 100%;
577
+ min-width: 0;
578
+ max-width: 100%;
579
+ border: 3px solid #6b5243;
580
+ border-radius: 14px;
581
+ padding: 12px 14px;
582
+ background: #fff;
583
+ color: #6b5243;
584
+ font: inherit;
585
+ transition: transform 0.12s ease;
586
+ }
587
+ .field-select, .row-select { box-sizing: border-box; appearance: none; }
588
+ input[type="number"], select { box-sizing: border-box; width: 100%; min-width: 0; max-width: 100%; }
589
+ .runtime-grid .field-label, .balance-grid .field-label {
590
+ box-sizing: border-box;
591
+ width: 100%;
592
+ max-width: 100%;
593
+ overflow: hidden; overflow: hidden; }
594
+ .field-input:focus-visible, .field-select:focus-visible, .row-input:focus-visible, .row-select:focus-visible, .file-input:focus-visible, textarea.field-input:focus-visible {
595
+ outline: none;
596
+ transform: translate(-1px, -1px);
597
+ }
598
+ .compact-input { padding: 10px 12px; }
599
+ .footer-actions { margin-top: 20px; display: flex; justify-content: flex-end; }
600
+ @media (max-width: 900px) {
601
+ .config-grid, .summary-grid, .row-editor, .priority-top,
602
+ .runtime-grid .field-label, .balance-grid .field-label {
603
+ box-sizing: border-box;
604
+ width: 100%;
605
+ max-width: 100%;
606
+ overflow: hidden;
607
+ grid-template-columns: 1fr;
608
+ }
609
+ }
610
+ .modal-overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(87, 64, 46, 0.35); backdrop-filter: blur(4px); overscroll-behavior: contain; }
611
+ .simple-modal { width: min(560px, 100%); max-height: 88vh; overflow: auto; padding: 22px; background: #fffdf7; }
612
+ .simple-modal-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
613
+ .modal-close { width: 40px; height: 40px; border: 3px solid #6b5243; border-radius: 12px; background: #fff; color: #6b5243; font-size: 24px; font-weight: 900; line-height: 1; cursor: pointer; }
614
+ .modal-close:hover, .modal-close:focus-visible { transform: translate(-1px, -1px); }
615
+ .modal-close:active { transform: translate(2px, 2px); }
616
+ .modal-note { margin: 0 0 16px; color: #8b6b57; word-break: break-word; }
617
+ .file-input { border: 3px solid #6b5243; border-radius: 16px; padding: 10px; background: #fff; }
618
+ .textarea-input { min-height: 220px; resize: vertical; }
619
+ .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 1100; padding: 12px 16px; border: 3px solid #6b5243; border-radius: 16px; background: #fff; }
620
+ .toast.success { background: #d9f6c8; }
621
+ .toast.error { background: #ffd6d9; }
622
+ @media (max-width: 900px) { .config-grid, .summary-grid, .row-editor, .priority-top, .form-row { grid-template-columns: 1fr; } .form-row { align-items: start; } }
623
+ @media (max-width: 768px) { .page-shell { padding: 12px; } .page-body { } .hero-card, .panel-card { padding: 18px; } .hero-card, .panel-header, .sub-panel-header, .summary-card, .section-title-row, .server-group-header { flex-direction: column; } .hero-actions, .create-actions, .card-actions, .compact-actions, .footer-actions { width: 100%; } .hero-actions > *, .create-actions > *, .card-actions > *, .compact-actions > *, .footer-actions > * { width: 100%; } .tab-bar { display: grid; } .bot-grid { grid-template-columns: 1fr; } }
624
+ </style>