koishi-plugin-adapter-onebot-multi 0.0.18 → 1.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,104 +1,1089 @@
1
1
  <template>
2
2
  <k-layout>
3
- <div v-if="loading" class="loading-state">
4
- <span>加载中...</span>
5
- </div>
6
- <div v-else-if="!panelEnabled" class="disabled-state">
7
- <h2>面板未启用</h2>
8
- <p>请在插件配置中启用「展示面板」功能</p>
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>
59
+ </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>
151
+
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>
206
+ </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>
212
+ </div>
213
+ </div>
214
+ </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>
223
+
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>
250
+
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>
263
+
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>
279
+
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>
290
+ </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>
9
300
  </div>
10
- <iframe
11
- v-else
12
- :src="adminUrl"
13
- class="admin-iframe"
14
- />
15
301
  </k-layout>
16
302
  </template>
17
303
 
18
- <script lang="ts" setup>
19
- import { ref, computed, onMounted } from 'vue'
304
+ <script setup lang="ts">
305
+ import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
20
306
  import { send } from '@koishijs/client'
21
307
 
22
- interface PanelConfig {
23
- basePath: string
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 {
24
339
  enabled: boolean
25
- port: number | null
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[]>
26
349
  }
27
350
 
28
351
  const loading = ref(true)
29
- const panelConfig = ref<PanelConfig | null>(null)
352
+ const activeTab = ref<'bots' | 'balance'>('bots')
353
+ const bots = ref<ManagedBot[]>([])
30
354
 
31
- const panelEnabled = computed(() => panelConfig.value?.enabled ?? false)
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
+ })
32
365
 
33
- // 根据当前页面 hostname 动态构建 URL
34
- const adminUrl = computed(() => {
35
- if (!panelConfig.value) return ''
36
- const { basePath, port } = panelConfig.value
37
- if (port) {
38
- // 使用当前页面的 hostname,支持服务器部署
39
- return `${window.location.protocol}//${window.location.hostname}:${port}${basePath}/admin`
40
- }
41
- return `${basePath}/admin`
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: {},
42
377
  })
43
378
 
44
- onMounted(async () => {
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
+
404
+ let refreshTimer: ReturnType<typeof setInterval> | null = null
405
+ 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 filtered = listEditorText.value
434
+ .split('\n')
435
+ .map(v => v.trim())
436
+ .filter(v => /^\d+$/.test(v))
437
+ .join('\n')
438
+
439
+ if (listEditorTarget.value === 'blacklist') blacklistText.value = filtered
440
+ else if (listEditorTarget.value === 'whitelist') whitelistText.value = filtered
441
+ else if (listEditorTarget.value === 'priority') priorityText.value = filtered
442
+
443
+ listEditorTarget.value = null
444
+ showToast('success', '列表已暂存,请点击保存规则生效')
445
+ }
446
+
447
+ function showToast(type: 'success' | 'error', message: string) {
448
+ toast.type = type
449
+ toast.message = message
450
+ toast.show = true
451
+ setTimeout(() => { toast.show = false }, 2000)
452
+ }
453
+
454
+ function statusText(status?: BotStatus) {
455
+ if (status === 'online') return '在线'
456
+ if (status === 'connecting') return '连接中'
457
+ return '离线'
458
+ }
459
+
460
+ function applyLoadBalanceText() {
461
+ blacklistText.value = loadBalance.channelBlacklist.join('\n')
462
+ whitelistText.value = loadBalance.channelWhitelist.join('\n')
463
+ priorityText.value = loadBalance.priorityChannels.join('\n')
464
+
465
+ botMaxLoadRows.value = Object.entries(loadBalance.botMaxLoad || {}).map(([botId, maxLoad]) => ({
466
+ botId,
467
+ maxLoad: Number(maxLoad) || 0,
468
+ }))
469
+
470
+ channelPriorityRows.value = Object.entries(loadBalance.channelBotPriority || {}).map(([channelId, botIds]) => ({
471
+ channelId,
472
+ botIds: Array.isArray(botIds) ? [...botIds] : [],
473
+ }))
474
+ }
475
+
476
+ function addBotMaxRow() {
477
+ botMaxLoadRows.value.push({ botId: '', maxLoad: 0 })
478
+ }
479
+
480
+ function removeBotMaxRow(index: number) {
481
+ botMaxLoadRows.value.splice(index, 1)
482
+ }
483
+
484
+ function addChannelPriorityRow() {
485
+ channelPriorityRows.value.push({ channelId: '', botIds: [] })
486
+ }
487
+
488
+ function removeChannelPriorityRow(index: number) {
489
+ channelPriorityRows.value.splice(index, 1)
490
+ }
491
+
492
+ async function refreshAll() {
493
+ if (refreshing) return
494
+ refreshing = true
45
495
  try {
46
- panelConfig.value = await (send as any)('onebot-multi/config')
47
- } catch (e) {
48
- console.error('Failed to get panel config:', e)
496
+ const state = await (send as any)('onebot-multi/state')
497
+ bots.value = state?.bots || []
498
+ Object.assign(runtime, state?.runtime || {})
499
+ Object.assign(loadBalance, state?.loadBalance || {})
500
+ applyLoadBalanceText()
501
+ } catch (error: any) {
502
+ showToast('error', error?.message || '刷新失败')
49
503
  } finally {
50
- loading.value = false
504
+ refreshing = false
505
+ }
506
+ }
507
+
508
+ function openCreate() {
509
+ editingMode.value = 'create'
510
+ editingOldSelfId.value = ''
511
+ editing.value = {
512
+ token: '',
513
+ protocol: 'ws-reverse',
514
+ endpoint: '',
515
+ path: '/onebot',
516
+ name: '',
517
+ enabled: true,
518
+ }
519
+ }
520
+
521
+ function openEdit(bot: ManagedBot) {
522
+ editingMode.value = 'edit'
523
+ editingOldSelfId.value = bot.selfId
524
+ editing.value = {
525
+ ...bot,
526
+ }
527
+ }
528
+
529
+ async function saveBot() {
530
+ if (!editing.value) return
531
+ try {
532
+ if (editingMode.value === 'create') {
533
+ await (send as any)('onebot-multi/bots/create', editing.value)
534
+ } else {
535
+ await (send as any)('onebot-multi/bots/update', {
536
+ oldSelfId: editingOldSelfId.value,
537
+ bot: editing.value,
538
+ })
539
+ }
540
+
541
+ editing.value = null
542
+ await refreshAll()
543
+ showToast('success', '保存成功')
544
+ } catch (error: any) {
545
+ showToast('error', error?.message || '保存失败')
546
+ }
547
+ }
548
+
549
+ async function removeBot(bot: ManagedBot) {
550
+ if (!confirm(`确认删除 Bot ${bot.selfId} 吗?`)) return
551
+ try {
552
+ await (send as any)('onebot-multi/bots/delete', { selfId: bot.selfId })
553
+ await refreshAll()
554
+ showToast('success', '删除成功')
555
+ } catch (error: any) {
556
+ showToast('error', error?.message || '删除失败')
557
+ }
558
+ }
559
+
560
+ async function toggleBot(bot: ManagedBot) {
561
+ try {
562
+ await (send as any)('onebot-multi/bots/toggle', { selfId: bot.selfId })
563
+ await refreshAll()
564
+ showToast('success', '状态已更新')
565
+ } catch (error: any) {
566
+ showToast('error', error?.message || '更新失败')
567
+ }
568
+ }
569
+
570
+ async function restartBot(bot: ManagedBot) {
571
+ try {
572
+ await (send as any)('onebot-multi/bots/restart', { selfId: bot.selfId })
573
+ await refreshAll()
574
+ showToast('success', '重启已触发')
575
+ } catch (error: any) {
576
+ showToast('error', error?.message || '重启失败')
577
+ }
578
+ }
579
+
580
+ function openProfile(bot: ManagedBot) {
581
+ profileTarget.value = bot
582
+ profileNickname.value = bot.nickname || ''
583
+ }
584
+
585
+ async function saveProfile() {
586
+ if (!profileTarget.value) return
587
+ try {
588
+ const result = await (send as any)('onebot-multi/bots/profile', {
589
+ selfId: profileTarget.value.selfId,
590
+ nickname: profileNickname.value,
591
+ })
592
+ if (!result?.success) throw new Error(result?.error || '保存失败')
593
+ profileTarget.value = null
594
+ await refreshAll()
595
+ showToast('success', '昵称已更新')
596
+ } catch (error: any) {
597
+ showToast('error', error?.message || '更新失败')
598
+ }
599
+ }
600
+
601
+ function openAvatar(bot: ManagedBot) {
602
+ avatarTarget.value = bot
603
+ avatarFile.value = ''
604
+ }
605
+
606
+ function onAvatarFileChange(e: Event) {
607
+ const target = e.target as HTMLInputElement
608
+ const file = target.files?.[0]
609
+ if (!file) {
610
+ avatarFile.value = ''
611
+ return
612
+ }
613
+ const reader = new FileReader()
614
+ reader.onload = (ev) => {
615
+ avatarFile.value = ev.target?.result as string
616
+ }
617
+ reader.readAsDataURL(file)
618
+ }
619
+
620
+ async function saveAvatar() {
621
+ if (!avatarTarget.value || !avatarFile.value.trim()) {
622
+ showToast('error', '请先选择图片文件')
623
+ return
624
+ }
625
+ try {
626
+ const result = await (send as any)('onebot-multi/bots/avatar', {
627
+ selfId: avatarTarget.value.selfId,
628
+ file: avatarFile.value,
629
+ })
630
+ if (!result?.success) throw new Error(result?.error || '保存失败')
631
+ avatarTarget.value = null
632
+ showToast('success', '头像已更新')
633
+ } catch (error: any) {
634
+ showToast('error', error?.message || '更新失败')
635
+ }
636
+ }
637
+
638
+ async function saveRuntime() {
639
+ try {
640
+ await (send as any)('onebot-multi/settings/runtime/update', {
641
+ ...runtime,
642
+ })
643
+ await refreshAll()
644
+ showToast('success', '运行时设置已保存')
645
+ } catch (error: any) {
646
+ showToast('error', error?.message || '保存失败')
647
+ }
648
+ }
649
+
650
+ async function saveLoadBalance() {
651
+ try {
652
+ const botMaxLoad: Record<string, number> = {}
653
+ for (const row of botMaxLoadRows.value) {
654
+ const botId = row.botId.trim()
655
+ if (!botId) continue
656
+ const maxLoad = Math.max(0, Number(row.maxLoad || 0))
657
+ botMaxLoad[botId] = maxLoad
658
+ }
659
+
660
+ const channelBotPriority: Record<string, string[]> = {}
661
+ for (const row of channelPriorityRows.value) {
662
+ const channelId = row.channelId.trim()
663
+ if (!channelId) continue
664
+ const botIds = (row.botIds || []).filter(Boolean)
665
+ if (botIds.length > 0) {
666
+ channelBotPriority[channelId] = botIds
667
+ }
668
+ }
669
+
670
+ const payload = {
671
+ ...loadBalance,
672
+ channelBlacklist: lineArray(blacklistText.value),
673
+ channelWhitelist: lineArray(whitelistText.value),
674
+ priorityChannels: lineArray(priorityText.value),
675
+ botMaxLoad,
676
+ channelBotPriority,
677
+ }
678
+
679
+ await (send as any)('onebot-multi/settings/load-balance/update', payload)
680
+ await refreshAll()
681
+ showToast('success', '负载均衡设置已保存')
682
+ } catch (error: any) {
683
+ showToast('error', error?.message || '保存失败')
684
+ }
685
+ }
686
+
687
+ onMounted(async () => {
688
+ await refreshAll()
689
+ loading.value = false
690
+ refreshTimer = setInterval(() => {
691
+ refreshAll()
692
+ }, 5000)
693
+ })
694
+
695
+ onUnmounted(() => {
696
+ if (refreshTimer) {
697
+ clearInterval(refreshTimer)
698
+ refreshTimer = null
51
699
  }
52
700
  })
53
701
  </script>
54
702
 
55
703
  <style scoped lang="scss">
56
- /* 去除 k-layout 所有默认样式,让 iframe 完全填充 */
57
- :deep(.k-layout) {
704
+ :deep(.k-layout),
705
+ :deep(.k-layout__main) {
58
706
  background: transparent !important;
59
707
  padding: 0 !important;
60
708
  margin: 0 !important;
709
+ height: 100%;
710
+ position: relative;
61
711
  }
62
712
 
63
- :deep(.k-layout__main) {
64
- background: transparent !important;
65
- padding: 0 !important;
66
- margin: 0 !important;
67
- overflow: hidden !important;
713
+ .pop-wrapper {
714
+ /* Core Palette */
715
+ --pop-bg: #fffbeb; /* Soft Beige/Warm Yellow */
716
+ --pop-text: #6b5243; /* Soft Brown */
717
+ --pop-border: 3px solid var(--pop-text);
718
+ --pop-shadow-color: var(--pop-text);
719
+ --pop-panel: #ffffff;
720
+ --pop-primary: #ff9a9e; /* Soft Pink */
721
+ --pop-secondary: #a1c4fd; /* Soft Blue */
722
+ --pop-success: #b2fba5; /* Soft Green */
723
+ --pop-danger: #ffb3ba; /* Soft Red */
724
+
725
+ /* Dots for halftone */
726
+ --pop-dot: rgba(107, 82, 67, 0.05); /* very subtle */
727
+
728
+ width: 100%;
729
+ height: 100%;
730
+ box-sizing: border-box;
731
+ color: var(--pop-text);
732
+ font-family: 'Nunito', 'Varela Round', 'PingFang SC', 'Microsoft YaHei', sans-serif;
733
+
734
+ background-color: var(--pop-bg);
735
+ background-image:
736
+ radial-gradient(var(--pop-dot) 15%, transparent 16%),
737
+ radial-gradient(var(--pop-dot) 15%, transparent 16%);
738
+ background-size: 24px 24px;
739
+ background-position: 0 0, 12px 12px;
740
+
741
+ position: absolute;
742
+ top: 0;
743
+ left: 0;
744
+ right: 0;
745
+ bottom: 0;
746
+ z-index: 0;
747
+ overflow-y: auto;
748
+ padding: 24px;
749
+
750
+ /* Bold, uppercase emphasis */
751
+ font-weight: 800;
68
752
  }
69
753
 
70
- :deep(.k-layout__header) {
71
- display: none !important;
754
+ .pop-wrapper * {
755
+ box-sizing: border-box;
72
756
  }
73
757
 
74
- :deep(.k-layout__left),
75
- :deep(.k-layout__right) {
76
- display: none !important;
758
+ /* Base Comic Style */
759
+ .comic-panel {
760
+ background: var(--pop-panel);
761
+ border: var(--pop-border);
762
+ box-shadow: 4px 4px 0 var(--pop-shadow-color);
763
+ border-radius: 16px;
77
764
  }
78
765
 
79
- .admin-iframe {
80
- width: 100%;
81
- height: 100%;
82
- border: none;
83
- display: block;
766
+ /* Loading */
767
+ .pop-loading {
768
+ display: flex;
769
+ justify-content: center;
770
+ align-items: center;
771
+ height: 60vh;
772
+ }
773
+ .comic-text {
774
+ font-size: 4rem;
775
+ color: #fff;
776
+ 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);
777
+ transform: rotate(-2deg);
778
+ animation: pulse 1s infinite alternate;
779
+ }
780
+
781
+ @keyframes pulse {
782
+ 0% { transform: rotate(-5deg) scale(1); }
783
+ 100% { transform: rotate(-5deg) scale(1.1); }
84
784
  }
85
785
 
86
- .loading-state,
87
- .disabled-state {
786
+ .pop-container {
787
+ max-width: 1200px;
788
+ margin: 0 auto;
88
789
  display: flex;
89
790
  flex-direction: column;
791
+ gap: 24px;
792
+ }
793
+
794
+ /* HEADER */
795
+ .pop-header {
796
+ display: flex;
797
+ justify-content: space-between;
90
798
  align-items: center;
91
- justify-content: center;
92
- height: 100%;
93
- color: var(--k-text-light);
799
+ gap: 16px;
800
+ flex-wrap: wrap;
801
+ background: var(--pop-secondary);
802
+ border: var(--pop-border);
803
+ box-shadow: 4px 4px 0 var(--pop-shadow-color);
804
+ padding: 20px 24px;
805
+ border-radius: 16px;
806
+ position: relative;
807
+ overflow: hidden;
808
+ }
809
+ .pop-header::before {
810
+ content: '';
811
+ position: absolute;
812
+ top: -50%; left: -50%; width: 200%; height: 200%;
813
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,0.2) 10px, rgba(255,255,255,0.2) 20px);
814
+ z-index: 0;
815
+ }
816
+ .pop-header > * { position: relative; z-index: 1; }
817
+
818
+ .brand { display: flex; align-items: center; gap: 16px; }
819
+ .logo {
820
+ font-size: 42px; background: var(--pop-bg); width: 72px; height: 72px;
821
+ border-radius: 50%; border: var(--pop-border);
822
+ display: flex; align-items: center; justify-content: center;
823
+ box-shadow: 4px 4px 0 var(--pop-shadow-color); transform: rotate(-5deg);
824
+ }
825
+
826
+ .title-box h1 {
827
+ margin: 0; font-size: 36px; font-weight: 900; color: #fff;
828
+ 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);
829
+ letter-spacing: 2px; text-transform: uppercase;
830
+ }
831
+ .title-box p {
832
+ margin: 4px 0 0; background: var(--pop-text); color: #fff; display: inline-block;
833
+ padding: 4px 8px; font-size: 14px; transform: skewX(-5deg); text-transform: uppercase;
834
+ }
835
+
836
+ .pop-tabs { display: flex; gap: 12px; flex-wrap: wrap; }
837
+
838
+ /* BUTTONS */
839
+ .pop-btn {
840
+ background: #fff; color: var(--pop-text); border: var(--pop-border);
841
+ box-shadow: 4px 4px 0 var(--pop-shadow-color); padding: 10px 20px;
842
+ font-size: 16px; font-family: inherit; font-weight: 900; cursor: pointer;
843
+ border-radius: 16px; transition: all 0.1s ease; position: relative; text-transform: uppercase;
844
+ }
845
+ .pop-btn:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--pop-shadow-color); }
846
+ .pop-btn:active { transform: translate(4px, 4px); box-shadow: 0px 0px 0 var(--pop-shadow-color); }
847
+ .pop-btn.active { background: var(--pop-primary); color: #fff; }
848
+ .pop-btn.primary-btn { background: var(--pop-primary); color: #fff; font-size: 18px; }
849
+ .pop-btn.action-btn { background: #ffb347; color: #fff; }
850
+
851
+ .pop-btn.mini { padding: 6px 12px; font-size: 13px; box-shadow: 2px 2px 0 var(--pop-shadow-color); border-width: 2px; }
852
+ .pop-btn.mini:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--pop-shadow-color); }
853
+ .pop-btn.mini:active { transform: translate(3px, 3px); box-shadow: 0px 0px 0 var(--pop-shadow-color); }
854
+ .pop-btn.danger { background: var(--pop-danger); color: #fff; }
855
+
856
+ /* PANELS */
857
+ .pop-panel {
858
+ background: var(--pop-panel); border: var(--pop-border); box-shadow: 4px 4px 0 var(--pop-shadow-color);
859
+ border-radius: 16px; padding: 24px; position: relative;
94
860
  }
861
+ .panel-header {
862
+ display: flex; justify-content: space-between; align-items: flex-start;
863
+ margin-bottom: 24px; border-bottom: 3px solid var(--pop-text); padding-bottom: 16px;
864
+ }
865
+ .panel-title h2 { margin: 0; font-size: 28px; color: var(--pop-primary); text-shadow: 2px 2px 0 var(--pop-text); text-transform: uppercase; }
866
+ .panel-title p { margin: 4px 0 0; font-size: 16px; font-weight: bold; text-transform: uppercase; }
867
+
868
+ /* BOT GRID */
869
+ .bot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
870
+ .bot-card {
871
+ border: var(--pop-border); border-radius: 16px; background: #fff; padding: 16px;
872
+ box-shadow: 4px 4px 0 var(--pop-shadow-color); transition: transform 0.2s, box-shadow 0.2s;
873
+ position: relative; overflow: hidden;
874
+ }
875
+ .bot-card::before {
876
+ content: ''; position: absolute; top: 0; right: 0; width: 40px; height: 40px;
877
+ background: var(--pop-bg); border-bottom-left-radius: 20px; border-left: var(--pop-border); border-bottom: var(--pop-border);
878
+ }
879
+ .bot-card.disabled { background: #e5e7eb; opacity: 0.8; }
880
+ .bot-card.disabled .bot-avatar { filter: grayscale(1); }
881
+ .bot-card:hover { transform: translate(-4px, -4px); box-shadow: 6px 6px 0 var(--pop-shadow-color); }
882
+
883
+ .bot-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
884
+ .bot-avatar {
885
+ width: 64px; height: 64px; border-radius: 50%; border: var(--pop-border);
886
+ background: var(--pop-bg); object-fit: cover; box-shadow: 2px 2px 0 var(--pop-text);
887
+ }
888
+ .bot-info { flex: 1; overflow: hidden; }
889
+ .bot-name { font-size: 20px; font-weight: 900; margin-bottom: 4px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; text-transform: uppercase; }
890
+ .bot-sub {
891
+ font-size: 13px; color: var(--pop-text); background: var(--pop-bg);
892
+ display: inline-block; padding: 2px 6px; border: 2px solid var(--pop-text); border-radius: 4px; margin-bottom: 4px;
893
+ }
894
+ .bot-id { font-family: 'Courier New', Courier, monospace; font-size: 12px; font-weight: bold; }
895
+
896
+ .status-badge {
897
+ border: 3px solid var(--pop-text); border-radius: 16px; padding: 4px 10px;
898
+ font-size: 14px; font-weight: 900; box-shadow: 2px 2px 0 var(--pop-text); transform: rotate(2deg);
899
+ text-transform: uppercase;
900
+ }
901
+ .status-online { background: var(--pop-success); color: var(--pop-text); }
902
+ .status-offline { background: var(--pop-danger); color: #fff; }
903
+ .status-connecting { background: var(--pop-bg); color: var(--pop-text); }
904
+
905
+ .bot-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 16px; }
906
+ .stat-box {
907
+ display: flex; justify-content: space-between; padding: 6px 10px;
908
+ border: 2px solid var(--pop-text); border-radius: 10px; background: #f8f9fa; box-shadow: 2px 2px 0 var(--pop-shadow-color);
909
+ }
910
+ .stat-box span { color: #666; font-size: 12px; font-weight: bold; text-transform: uppercase; }
911
+ .stat-box strong { font-size: 16px; }
912
+
913
+ .bot-meta {
914
+ background: var(--pop-secondary); border: 2px solid var(--pop-text); padding: 8px;
915
+ border-radius: 10px; font-size: 12px; margin-bottom: 16px; word-break: break-all;
916
+ }
917
+ .bot-actions { display: flex; flex-wrap: wrap; gap: 8px; }
918
+
919
+ /* FORMS */
920
+ .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 24px; }
921
+ .modal-grid { grid-template-columns: 1fr; gap: 12px; max-height: 50vh; overflow-y: auto; padding-right: 8px; }
922
+
923
+ .pop-label { display: flex; flex-direction: column; gap: 8px; font-size: 16px; font-weight: bold; text-transform: uppercase; }
924
+ .pop-label.hidden { display: none; }
925
+
926
+ .pop-input, .pop-select, .pop-textarea {
927
+ border: 3px solid var(--pop-text); border-radius: 16px; padding: 10px 14px;
928
+ font-size: 15px; font-family: inherit; font-weight: bold; background: #fff; color: var(--pop-text);
929
+ box-shadow: inset 2px 2px 0 rgba(0,0,0,0.1); transition: all 0.2s;
930
+ }
931
+ .pop-input:focus, .pop-select:focus, .pop-textarea:focus {
932
+ outline: none; background: #fffde7; box-shadow: 4px 4px 0 var(--pop-shadow-color); transform: translate(-2px, -2px);
933
+ }
934
+ .pop-textarea { resize: vertical; }
935
+
936
+ .pop-file-input {
937
+ border: 3px solid var(--pop-text);
938
+ border-radius: 16px;
939
+ padding: 8px;
940
+ font-size: 14px;
941
+ font-family: inherit;
942
+ font-weight: bold;
943
+ background: #fff;
944
+ color: var(--pop-text);
945
+ box-shadow: inset 2px 2px 0 rgba(0,0,0,0.1);
946
+ transition: all 0.2s;
947
+ cursor: pointer;
948
+ }
949
+ .pop-file-input:focus, .pop-file-input:hover {
950
+ outline: none; background: #fffde7; box-shadow: 4px 4px 0 var(--pop-shadow-color); transform: translate(-2px, -2px);
951
+ }
952
+ .pop-file-input::file-selector-button {
953
+ background: var(--pop-primary);
954
+ color: #fff;
955
+ border: 2px solid var(--pop-text);
956
+ border-radius: 8px;
957
+ padding: 6px 12px;
958
+ font-weight: bold;
959
+ cursor: pointer;
960
+ margin-right: 12px;
961
+ text-transform: uppercase;
962
+ }
963
+
964
+ .pop-checkbox {
965
+ flex-direction: row; align-items: center; cursor: pointer; background: var(--pop-bg);
966
+ padding: 10px; border: 3px solid var(--pop-text); border-radius: 16px; box-shadow: 2px 2px 0 var(--pop-shadow-color);
967
+ transition: transform 0.1s;
968
+ }
969
+ .pop-checkbox:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--pop-shadow-color); }
970
+ .pop-check { width: 20px; height: 20px; accent-color: var(--pop-primary); border: 2px solid var(--pop-text); cursor: pointer; }
971
+
972
+ .mini-checkbox { padding: 4px 8px; border-radius: 8px; font-size: 13px; gap: 4px; border-width: 2px; }
973
+ .mini-check { width: 14px; height: 14px; }
974
+
975
+ /* SPLIT GRID FOR BALANCE */
976
+ .split-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 24px; }
977
+
978
+ .list-summary-box {
979
+ display: flex;
980
+ align-items: center;
981
+ justify-content: space-between;
982
+ background: #fff;
983
+ border: 3px solid var(--pop-text);
984
+ border-radius: 12px;
985
+ padding: 8px 12px;
986
+ box-shadow: 2px 2px 0 var(--pop-shadow-color);
987
+ margin-top: 4px;
988
+ }
989
+ .list-count {
990
+ font-size: 14px;
991
+ font-weight: 900;
992
+ color: var(--pop-primary);
993
+ }
994
+
995
+ /* TABLE EDITOR */
996
+ .pop-table-editor {
997
+ border: 3px dashed var(--pop-text); background: var(--pop-secondary); border-radius: 16px;
998
+ padding: 16px; display: flex; flex-direction: column; gap: 12px; position: relative;
999
+ }
1000
+ .pop-table-editor.full-width { grid-column: 1 / -1; background: #ffb3ff; }
1001
+
1002
+ .table-head {
1003
+ display: flex; justify-content: space-between; align-items: center;
1004
+ font-size: 18px; font-weight: 900; background: #fff; padding: 6px 12px;
1005
+ border: 3px solid var(--pop-text); border-radius: 16px; box-shadow: 2px 2px 0 var(--pop-text); text-transform: uppercase;
1006
+ }
1007
+ .table-body { display: flex; flex-direction: column; gap: 8px; max-height: 250px; overflow-y: auto; padding-right: 8px; }
1008
+ .empty-row {
1009
+ background: #fff; padding: 12px; text-align: center; border: 3px solid var(--pop-text);
1010
+ border-radius: 16px; font-weight: bold; text-transform: uppercase;
1011
+ }
1012
+ .editor-row {
1013
+ display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; background: #fff;
1014
+ padding: 8px; border: 3px solid var(--pop-text); border-radius: 16px; box-shadow: 2px 2px 0 var(--pop-text);
1015
+ }
1016
+ .editor-row.wide { grid-template-columns: 1fr 2fr auto; }
1017
+ .mini-input { padding: 6px 10px; font-size: 14px; }
1018
+ .panel-footer { margin-top: 24px; padding-top: 20px; border-top: 3px solid var(--pop-text); display: flex; justify-content: flex-end; }
1019
+
1020
+ /* MODALS */
1021
+ .pop-modal-overlay {
1022
+ position: fixed; inset: 0; background: rgba(107, 82, 67, 0.4); backdrop-filter: blur(4px);
1023
+ display: flex; align-items: center; justify-content: center; z-index: 1000;
1024
+ }
1025
+ .pop-modal { width: min(600px, 90vw); position: relative; padding: 32px; }
1026
+ .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; }
1027
+ .modal-sub {
1028
+ font-family: 'Courier New', Courier, monospace; background: var(--pop-bg); display: inline-block;
1029
+ padding: 4px 8px; border: 2px solid var(--pop-text); margin-bottom: 20px; font-weight: bold;
1030
+ }
1031
+ .comic-burst {
1032
+ position: absolute; top: -15px; right: -15px; background: var(--pop-bg); color: var(--pop-text);
1033
+ font-size: 24px; font-weight: 900; padding: 10px; border: var(--pop-border); border-radius: 50%;
1034
+ transform: rotate(5deg); box-shadow: 4px 4px 0 var(--pop-shadow-color);
1035
+ animation: pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); text-transform: uppercase;
1036
+ }
1037
+ @keyframes pop {
1038
+ 0% { transform: scale(0) rotate(0deg); }
1039
+ 100% { transform: scale(1) rotate(15deg); }
1040
+ }
1041
+ .modal-actions { display: flex; gap: 12px; margin-top: 24px; justify-content: flex-end; }
1042
+
1043
+ /* TOAST */
1044
+ .pop-toast {
1045
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
1046
+ padding: 12px 24px; font-size: 20px; font-weight: 900; z-index: 1100;
1047
+ display: flex; align-items: center; gap: 12px; animation: slideUp 0.3s ease-out; text-transform: uppercase;
1048
+ }
1049
+ .pop-toast.success { background: var(--pop-success); color: var(--pop-text); }
1050
+ .pop-toast.error { background: var(--pop-danger); color: #fff; }
1051
+ @keyframes slideUp { 0% { transform: translate(-50%, 100%); opacity: 0; } 100% { transform: translate(-50%, 0); opacity: 1; } }
1052
+
1053
+ /* EMPTY STATE */
1054
+ .empty-state { display: flex; justify-content: center; padding: 40px; }
1055
+ .comic-speech {
1056
+ position: relative; background: #fff; border: 3px solid var(--pop-text); border-radius: 16px;
1057
+ padding: 20px 30px; font-size: 24px; font-weight: bold; box-shadow: 4px 4px 0 var(--pop-shadow-color); text-transform: uppercase;
1058
+ }
1059
+ .comic-speech::after {
1060
+ display: none;
1061
+ content: ''; position: absolute; bottom: -20px; left: 30px;
1062
+ border-width: 12px 12px 0 0; border-style: solid; border-color: #fff transparent transparent transparent;
1063
+ }
1064
+ .comic-speech::before {
1065
+ display: none;
1066
+ content: ''; position: absolute; bottom: -26px; left: 28px;
1067
+ border-width: 14px 14px 0 0; border-style: solid; border-color: var(--pop-text) transparent transparent transparent; z-index: -1;
1068
+ }
1069
+
1070
+ /* CUSTOM SCROLLBAR */
1071
+ ::-webkit-scrollbar { width: 12px; }
1072
+ ::-webkit-scrollbar-track { background: var(--pop-bg); border-left: 3px solid var(--pop-text); }
1073
+ ::-webkit-scrollbar-thumb { background: var(--pop-primary); border: 3px solid var(--pop-text); border-radius: 0; }
95
1074
 
96
- .disabled-state h2 {
97
- margin-bottom: 0.5rem;
98
- color: var(--k-text-dark);
1075
+ @media (max-width: 960px) {
1076
+ .split-grid { grid-template-columns: 1fr; }
99
1077
  }
100
1078
 
101
- .disabled-state p {
102
- color: var(--k-text-light);
1079
+ @media (max-width: 768px) {
1080
+ .pop-wrapper { padding: 12px; }
1081
+ .pop-header { flex-direction: column; text-align: center; }
1082
+ .pop-tabs { justify-content: center; width: 100%; }
1083
+ .pop-btn { flex: 1; min-width: 45%; text-align: center; }
1084
+ .bot-actions .pop-btn { flex: 1; min-width: 30%; }
1085
+ .pop-modal { padding: 20px; }
1086
+ .comic-speech { font-size: 18px; }
1087
+ .bot-grid { grid-template-columns: 1fr; }
103
1088
  }
104
- </style>
1089
+ </style>