@zaofan/dsh-qqbot 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +17 -0
  2. package/README_EN.md +17 -0
  3. package/client/qqbot-settings.js +1385 -28
  4. package/dist/api/group-admin.d.ts +13 -0
  5. package/dist/api/group-admin.d.ts.map +1 -1
  6. package/dist/api/group-admin.js +23 -0
  7. package/dist/api/group-admin.js.map +1 -1
  8. package/dist/channel-tools.d.ts.map +1 -1
  9. package/dist/channel-tools.js +58 -1
  10. package/dist/channel-tools.js.map +1 -1
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +1 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/features/chat-ledger.d.ts.map +1 -1
  16. package/dist/features/chat-ledger.js +54 -3
  17. package/dist/features/chat-ledger.js.map +1 -1
  18. package/dist/features/qq-approval.d.ts +43 -1
  19. package/dist/features/qq-approval.d.ts.map +1 -1
  20. package/dist/features/qq-approval.js +167 -17
  21. package/dist/features/qq-approval.js.map +1 -1
  22. package/dist/features/qq-user-questions.d.ts +69 -0
  23. package/dist/features/qq-user-questions.d.ts.map +1 -0
  24. package/dist/features/qq-user-questions.js +214 -0
  25. package/dist/features/qq-user-questions.js.map +1 -0
  26. package/dist/features/session-registry.d.ts +51 -0
  27. package/dist/features/session-registry.d.ts.map +1 -0
  28. package/dist/features/session-registry.js +95 -0
  29. package/dist/features/session-registry.js.map +1 -0
  30. package/dist/gateway/bootstrap.d.ts.map +1 -1
  31. package/dist/gateway/bootstrap.js +88 -1
  32. package/dist/gateway/bootstrap.js.map +1 -1
  33. package/dist/session/session-manager.d.ts.map +1 -1
  34. package/dist/session/session-manager.js +9 -14
  35. package/dist/session/session-manager.js.map +1 -1
  36. package/dist/transport/outbound-buffer.d.ts +2 -0
  37. package/dist/transport/outbound-buffer.d.ts.map +1 -1
  38. package/dist/transport/outbound-buffer.js.map +1 -1
  39. package/package.json +6 -3
  40. package/settings-host.js +962 -5
  41. package/voice-convert.mjs +45 -0
@@ -137,6 +137,7 @@ window.__ModuleLoader__.load({
137
137
 
138
138
  function nv(v) { return typeof v === 'number' && isFinite(v) ? v : 0 }
139
139
  function sv(v) { return typeof v === 'string' ? v : '' }
140
+ function esc(v) { return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
140
141
 
141
142
  function BoolRow(props) {
142
143
  return h('label', { style: labelStyle },
@@ -373,6 +374,7 @@ window.__ModuleLoader__.load({
373
374
  // ── ⑥ QQ 群管理(单开大卡片; P3+ 设计系统版: 横排 Tab + 状态条 + 表格 + 空状态 + 预留接口位) ──
374
375
  // 能力就绪度: 官方未开放位保留(红条+行动), 不置灰糊弄; 业务名词做 Tab 骨架。
375
376
  var GROUP_TABS = [
377
+ { key: 'send', label: '发消息', open: true },
376
378
  { key: 'join', label: '入群审批', open: true },
377
379
  { key: 'mute', label: '禁言', open: true },
378
380
  { key: 'members', label: '成员信息', open: false, gateHuman: '获取群成员列表: 官方尚未开放(内邀中), 等待公测后自动可用' },
@@ -392,6 +394,7 @@ window.__ModuleLoader__.load({
392
394
  var [members, setMembers] = useState(null)
393
395
  var [muteSecs, setMuteSecs] = useState('60')
394
396
  var [muteTarget, setMuteTarget] = useState('')
397
+ var [sendText, setSendText] = useState('')
395
398
 
396
399
  function loadMembers() {
397
400
  if (!gid) return
@@ -460,9 +463,25 @@ window.__ModuleLoader__.load({
460
463
  else setMsg((d && d.error) || '绑定失败')
461
464
  }).catch(function (e) { setMsg('绑定异常: ' + e.message) })
462
465
  }
466
+ function doSend() {
467
+ var t = sendText.trim()
468
+ if (!t) { setMsg('先输入要发送的内容'); return }
469
+ setBusy('send')
470
+ fetch('/api/qqbot-settings/group/send', {
471
+ method: 'POST', headers: { 'content-type': 'application/json' },
472
+ body: JSON.stringify(Object.assign({ gid: gid, text: t }, props.ns ? { ns: props.ns } : {})),
473
+ }).then(function (r) { return r.json() }).then(function (d) {
474
+ setBusy('')
475
+ setMsg((d && d.msg) || (d && d.err && d.err.human) || (d && d.error) || '发送结果未知')
476
+ if (d && d.ok) setSendText('')
477
+ }).catch(function (e) { setBusy(''); setMsg('发送异常: ' + e.message) })
478
+ }
463
479
 
464
480
  var curTabMeta = null
465
481
  GROUP_TABS.forEach(function (t) { if (t.key === tab) curTabMeta = t })
482
+ var curGroup = null
483
+ ;(accts || []).forEach(function (a) { if (a.gid === gid) curGroup = a })
484
+ var groupSelName = (curGroup && curGroup.name) ? curGroup.name : ''
466
485
  var groupSel = accts === null ? h('span', { style: { fontSize: T.fsSm, color: T.text3 } }, '群列表加载中…')
467
486
  : accts.length === 0 ? h('span', { style: { fontSize: T.fsSm, color: T.warn } }, '暂无群(事件累积/下方登记后出现)')
468
487
  : h('select', { className: 'qqs-inp', style: { padding: '3px 8px', maxWidth: 320, border: '1px solid ' + T.border, borderRadius: T.radius }, value: gid, onChange: function (e) { setGid(e.target.value); setMsg('') } },
@@ -511,6 +530,14 @@ window.__ModuleLoader__.load({
511
530
  '此功能位已预留 —— 官方开放接口后此处自动点亮, 无需等待插件更新'))
512
531
  } else if (!gid) {
513
532
  body = h(Empty, { icon: '👥', text: '先选择或登记一个群, 再查看/操作' })
533
+ } else if (tab === 'send') {
534
+ // 发消息: 以机器人身份直接向目标群发文本(面板=主人直发)
535
+ body = h('div', null,
536
+ h(StatusBar, { tone: 'info' }, '以机器人身份向「' + (groupSelName) + '」发消息 —— 内容会以 bot 名义出现在群里。'),
537
+ h('textarea', { className: 'qqs-area', style: { width: '100%', minHeight: 90, marginTop: 8 }, placeholder: '输入要发的消息…(@某人 用 <@对方openid> 无斜杠 或 <qqbot-at-user id="对方openid" />)', value: sendText, onChange: function (e) { setSendText(e.target.value) } }),
538
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 8 } },
539
+ h(SButton, { variant: 'primary', disabled: busy === 'send', onClick: doSend }, busy === 'send' ? '发送中…' : '🚀 发送到群'),
540
+ h('span', { style: { fontSize: T.fsSm, color: T.text3 } }, '已输 ' + sendText.length + '/2000 字')))
514
541
  } else if (tab === 'join') {
515
542
  body = joins === null ? h(Empty, { icon: '⏳', text: '加载中…' })
516
543
  : h('div', null,
@@ -1016,7 +1043,7 @@ window.__ModuleLoader__.load({
1016
1043
  var [msg, setMsg] = useState('')
1017
1044
  var [busyId, setBusyId] = useState('')
1018
1045
  // 名称解析(2026-09-05): 群=活群真名(accounts 已剔除注销群), 私聊=台账昵称+成员表补名
1019
- var [chatTg, setChatTg] = useState({ group: [], c2c: [] })
1046
+ var [chatTg, setChatTg] = useState(null) // 活群/昵称台账: null=加载中(不判失效), 数组=已加载
1020
1047
  function load() {
1021
1048
  fetch(API_TIMERS + _tmQ).then(function (r) { return r.json() }).then(function (d) {
1022
1049
  if (d && Array.isArray(d.jobs)) setJobs(d.jobs.slice().reverse())
@@ -1025,14 +1052,17 @@ window.__ModuleLoader__.load({
1025
1052
  }
1026
1053
  useEffect(function () {
1027
1054
  load()
1028
- fetchChatTargets(props.acct).then(function (t) { setChatTg(t) }).catch(function () {})
1055
+ fetchChatTargets(props.acct).then(function (t) { setChatTg(t && t.group ? t : { group: [], c2c: [] }) }).catch(function () { setChatTg({ group: [], c2c: [] }) })
1029
1056
  }, [])
1030
1057
  function nameOf(scope, id) {
1058
+ if (!chatTg) return ''
1031
1059
  var nm = ''
1032
1060
  ;(chatTg[scope] || []).forEach(function (x) { if (x.id === id) nm = x.name || nm })
1033
1061
  return nm
1034
1062
  }
1035
1063
  function isGroupAlive(gid) {
1064
+ // 台账未加载(null)时不判失效——避免每次进页面先闪"群已失效"; 加载完成后按真实台账判定
1065
+ if (!chatTg) return true
1036
1066
  return (chatTg.group || []).some(function (g) { return g.id === gid })
1037
1067
  }
1038
1068
  function act(job, body, okMsg) {
@@ -1215,6 +1245,14 @@ window.__ModuleLoader__.load({
1215
1245
  var [copy, setCopy] = useState(null) // 复制弹层: {sourceId}
1216
1246
  var [copyId, setCopyId] = useState('')
1217
1247
  var [copyName, setCopyName] = useState('')
1248
+ var [collapsed, setCollapsed] = useState({})
1249
+ var [addMenu, setAddMenu] = useState(false)
1250
+ var [editP, setEditP] = useState(null)
1251
+ var [editFiles, setEditFiles] = useState([])
1252
+ var [editFile, setEditFile] = useState('')
1253
+ var [editContent, setEditContent] = useState('')
1254
+ var [editMsg, setEditMsg] = useState('')
1255
+ var [editBusy, setEditBusy] = useState(false)
1218
1256
  var pollRef = React.useRef(null)
1219
1257
  function load(quiet) {
1220
1258
  Promise.all([
@@ -1239,8 +1277,8 @@ window.__ModuleLoader__.load({
1239
1277
  useEffect(function () {
1240
1278
  if (!Array.isArray(presets) || presets.length === 0) return
1241
1279
  if (!Array.isArray(insts) || insts.length === 0) return
1242
- var know = presets.every(function (p) { return p.hasChannelTools === true || p.hasChannelTools === false })
1243
- var only = know ? presets.filter(function (p) { return p.hasChannelTools }) : presets
1280
+ // QQ 工具由会话自动挂载(2026-09-06) 预设不再需要带标, 全部可作候选
1281
+ var only = presets
1244
1282
  if (only.length === 0) return
1245
1283
  var changed = false
1246
1284
  var next = insts.map(function (x) {
@@ -1252,6 +1290,7 @@ window.__ModuleLoader__.load({
1252
1290
  }, [insts, presets])
1253
1291
  useEffect(function () { return function () { if (pollRef.current) clearInterval(pollRef.current) } }, [])
1254
1292
  function upd(i, patch) { setInsts(function (list) { var n = list.slice(); n[i] = Object.assign({}, n[i], patch); return n }) }
1293
+ function toggleCollapse(id) { setCollapsed(function (m) { var n = Object.assign({}, m); n[id] = !n[id]; return n }) }
1255
1294
  function addBlank() {
1256
1295
  var base = 'im-qqbot'
1257
1296
  var used = new Set((insts || []).map(function (x) { return x.id }))
@@ -1274,8 +1313,7 @@ window.__ModuleLoader__.load({
1274
1313
  var blanks = list.filter(function (x) { return !x.appSecret && !x.hasSecret && !x._bound })
1275
1314
  if (blanks.length && !window.confirm('有 ' + blanks.length + ' 个账号没填 appSecret(不填的会被保存为只有骨架的实例, 可能启动就弹扫码)。继续?')) { setBusy(''); return }
1276
1315
  // 保存兜底: preset 仍为空的账号自动填第一个候选(防 effect 未触发/用户未手动选导致保存丢 preset)
1277
- var knowNow = presets.length > 0 && presets.every(function (p) { return p.hasChannelTools === true || p.hasChannelTools === false })
1278
- var candNow = knowNow ? presets.filter(function (p) { return p.hasChannelTools }) : presets
1316
+ var candNow = presets
1279
1317
  if (candNow.length > 0) {
1280
1318
  var autoId = candNow[0].id
1281
1319
  list = list.map(function (x) { return x.preset ? x : Object.assign({}, x, { preset: autoId }) })
@@ -1351,31 +1389,76 @@ window.__ModuleLoader__.load({
1351
1389
  .then(function (r) { return r.json() }).then(function (d) { if (d && d.ok) setMsg('已在本机打开文件夹'); else if (d && d.error) setMsg(d.error) })
1352
1390
  .catch(function (e) { setMsg('打开失败: ' + e.message) })
1353
1391
  }
1392
+ // ── 人格编辑器(2026-09-07): 展开/改写预设人格文件(仅带QQ工具标记的副本可写, host 端二次校验) ──
1393
+ function loadEditFile(id, name) {
1394
+ fetch(API_PRESETS + '/file?id=' + encodeURIComponent(id) + '&name=' + encodeURIComponent(name))
1395
+ .then(function (r) { return r.json() }).then(function (d) {
1396
+ if (d && typeof d.content === 'string') { setEditContent(d.content); setEditMsg('') }
1397
+ else { setEditContent(''); setEditMsg('读取失败: ' + ((d && d.error) || '未知')) }
1398
+ }).catch(function (e) { setEditMsg('读取异常: ' + e.message) })
1399
+ }
1400
+ function openPresetEditor(p) {
1401
+ setEditP(p); setEditFiles([]); setEditFile(''); setEditContent(''); setEditMsg('')
1402
+ fetch(API_PRESETS + '/files?id=' + encodeURIComponent(p.id))
1403
+ .then(function (r) { return r.json() }).then(function (d) {
1404
+ if (d && Array.isArray(d.files)) {
1405
+ setEditP(function (prev) { return prev ? Object.assign({}, prev, { writable: !!d.writable }) : prev })
1406
+ setEditFiles(d.files)
1407
+ var def = d.files[0] ? d.files[0].name : ''
1408
+ setEditFile(def)
1409
+ if (def) loadEditFile(p.id, def)
1410
+ else setEditMsg('该预设没有可编辑文本文件')
1411
+ } else setEditMsg('读取失败: ' + ((d && d.error) || '未知'))
1412
+ }).catch(function (e) { setEditMsg('读取异常: ' + e.message) })
1413
+ }
1414
+ function pickEditFile(name) {
1415
+ setEditFile(name); setEditContent(''); setEditMsg('')
1416
+ if (editP) loadEditFile(editP.id, name)
1417
+ }
1418
+ function saveEditFile() {
1419
+ if (!editP || editBusy) return
1420
+ setEditBusy(true); setEditMsg('')
1421
+ fetch(API_PRESETS + '/file', {
1422
+ method: 'PUT', headers: { 'content-type': 'application/json' },
1423
+ body: JSON.stringify({ id: editP.id, name: editFile, content: editContent }),
1424
+ }).then(function (r) { return r.json() }).then(function (d) {
1425
+ setEditBusy(false)
1426
+ if (d && d.ok) setEditMsg('✅ 已保存 ' + d.name + ' —— 新会话生效')
1427
+ else setEditMsg('保存失败: ' + ((d && d.error) || '未知'))
1428
+ }).catch(function (e) { setEditBusy(false); setEditMsg('保存异常: ' + e.message) })
1429
+ }
1354
1430
  var st = { width: '100%', padding: '5px 8px', boxSizing: 'border-box' }
1355
1431
  return h('div', { style: { maxWidth: 860 } },
1356
- h('h2', null, '账号与预设'),
1432
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 } },
1433
+ h('h2', { style: { margin: 0 } }, '账号与预设'),
1434
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
1435
+ h('button', { className: 'qqs-btn', title: '保存账号配置(保存后重启生效)', onClick: save, disabled: busy === 'save', style: { fontSize: 16, padding: '4px 12px', lineHeight: '20px' } }, '💾'),
1436
+ h('button', { className: 'qqs-btn', title: '重新读取(放弃未保存的修改)', onClick: function () { load() }, style: { fontSize: 16, padding: '4px 12px', lineHeight: '20px' } }, '🔄'))),
1357
1437
  h('p', { style: { fontSize: 12, color: '#888' } }, '一个 QQ 机器人 = 一个账号实例(AppID/AppSecret)。想同时开几个机器人就加几个实例,每个可以配不同的 agent 预设(人格)与工作目录(各人各数据,不串)。⚠️ appSecret 会明文存在本机 cordis.patch.yml(改前自动备份 .bak),别把配置文件传上网。'),
1358
1438
  h('div', { style: sectionTitle }, '① 机器人账号(改完点保存, 重启生效)'),
1359
1439
  (insts === null ? h('p', null, '加载中…') : null),
1360
1440
  (insts || []).map(function (it, i) {
1361
- // 账号可选预设: 有 QQ 工具的优先。容错: host 若还没返回 hasChannelTools(旧版未重启), 则不锁死、照常全列, 保证能选能填;
1362
- // 一旦 host 给全了该字段, 就只列带 QQ 工具(不带行的选了在 QQ 跑不起来)
1363
- var knowChannel = presets.length > 0 && presets.every(function (p) { return p.hasChannelTools === true || p.hasChannelTools === false })
1364
- var usable = knowChannel ? presets.filter(function (p) { return p.hasChannelTools }) : presets.slice()
1441
+ // QQ 工具由机器人会话自动挂载(2026-09-06) 预设全列可选, 不再限定"带QQ工具"标记
1442
+ var usable = presets.slice()
1365
1443
  var selInvalid = it.preset && !usable.some(function (p) { return p.id === it.preset })
1366
1444
  var opts = usable.map(function (p) { return { id: p.id, label: p.name + ' (' + p.id + ')' } })
1367
- if (selInvalid) opts.unshift({ id: it.preset, label: '⚠️ ' + it.preset + '(不带QQ工具, 无法在QQ用)' })
1445
+ if (selInvalid) opts.unshift({ id: it.preset, label: '⚠️ ' + it.preset + '(预设不存在, 请重选)' })
1446
+ // 默认收起: 老账号折叠, 新建(_new/_bound)展开待填; 用户手动切换后以 collapsed 为准
1447
+ var folded = collapsed[it.id] !== undefined ? !!collapsed[it.id] : (!it._new && !it._bound)
1368
1448
  return h('div', { key: it.id, style: { border: '1px solid ' + (it.disabled ? '#ffa94d66' : '#00000026'), borderRadius: 10, padding: '8px 12px', margin: '8px 0', background: 'rgba(255,255,255,.5)' } },
1369
1449
  h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1370
- h('span', { style: { fontWeight: 800, color: '#171a21' } }, it._bound ? '📱 扫码绑定' : (it._new ? '🆕 新账号' : '🤖 账号')),
1450
+ h('button', { className: 'qqs-btn', title: folded ? '展开设置' : '收起设置', onClick: function () { toggleCollapse(it.id) }, style: { padding: '1px 7px', fontSize: 13, lineHeight: '16px', background: '#0000000d', borderColor: '#00000026' } }, folded ? '▸' : '▾'),
1451
+ h('span', { style: { fontWeight: 800, color: '#171a21', cursor: 'pointer', userSelect: 'none' }, onClick: function () { toggleCollapse(it.id) } }, it._bound ? '📱 扫码绑定' : (it._new ? '🆕 新账号' : '🤖 账号')),
1371
1452
  h('input', { className: 'qqs-inp', style: Object.assign({ width: 170 }, st), value: it.id, disabled: !it._new && !it._bound, onChange: function (e) { upd(i, { id: e.target.value }) }, placeholder: '实例 id(如 im-qqbot-2)' }),
1372
1453
  h('label', { style: { fontSize: 13, whiteSpace: 'nowrap' } },
1373
1454
  h('input', { className: 'qqs-cb', type: 'checkbox', checked: !it.disabled, onChange: function (e) { upd(i, { disabled: !e.target.checked }) } }), ' 启用'),
1455
+ h('span', { title: it.disabled ? '已停用' : (it.online ? '在线(绿点)' : '离线/未知(灰点)'), style: { width: 10, height: 10, borderRadius: '50%', display: 'inline-block', background: it.disabled ? '#adb5bd' : (it.online ? '#2f9e44' : '#ced4da'), boxShadow: it.online ? '0 0 0 3px rgba(47,158,68,.18)' : 'none', flex: '0 0 auto', marginLeft: 2 } }),
1374
1456
  h('button', { className: 'qqs-btn', onClick: function () { removeInst(i) }, style: { marginLeft: 'auto' } }, '删除')),
1375
- inputRow('AppID: ', h('input', { className: 'qqs-inp', style: Object.assign({ width: 220 }, st), value: it.appId || '', onChange: function (e) { upd(i, { appId: e.target.value }) }, placeholder: '如 1234567890' })),
1457
+ (folded ? null : h('div', { style: { marginTop: 4 } },
1458
+ inputRow('AppID: ', h('input', { className: 'qqs-inp', style: Object.assign({ width: 220 }, st), value: it.appId || '', onChange: function (e) { upd(i, { appId: e.target.value }) }, placeholder: '如 1234567890' })),
1376
1459
  inputRow('AppSecret: ', h('input', { className: 'qqs-inp', type: 'password', style: Object.assign({ width: 320 }, st), value: (it.appSecret === SECRET_MASK || (!it.appSecret && it.hasSecret)) ? '' : (it.appSecret || ''), onChange: function (e) { upd(i, { appSecret: e.target.value }) }, placeholder: (it.hasSecret && !it._new) ? '已保存(留空/掩码=保留原值; 填新值=更换)' : '扫码绑定会自动填; 也可手动填(两个都要填全才不弹码)' })),
1377
- inputRow('Agent 预设(人格): ', (function () { if (usable.length === 0 && !selInvalid) {
1378
- return h('span', { className: 'qqs-inp', style: { display: 'inline-block', verticalAlign: 'middle', padding: '5px 10px', fontSize: 12, color: '#e8590c', background: '#fff5f0', borderRadius: 6 } }, '⚠️ 还没有可用的 Agent 预设——请在下方②复制一个(如 whale-girl / whitegirl), 复制会自动带 QQ 工具。')
1460
+ inputRow('Agent 预设(人格): ', (function () { if (presets.length === 0 && !selInvalid) {
1461
+ return h('span', { className: 'qqs-inp', style: { display: 'inline-block', verticalAlign: 'middle', padding: '5px 10px', fontSize: 12, color: '#e8590c', background: '#fff5f0', borderRadius: 6 } }, '⚠️ 还没有任何 Agent 预设——请在下方②新建/复制一个')
1379
1462
  } return h('select', { className: 'qqs-inp', style: { width: 320, padding: '4px 8px' }, value: it.preset || '', onChange: function (e) { upd(i, { preset: e.target.value }) } }, opts.map(function (o) { return h('option', { key: o.id, value: o.id, style: selInvalid && o.id === it.preset ? { color: '#e03131' } : null }, o.label) })) })()),
1380
1463
  inputRow('工作目录(各账号数据放这, 留空=默认): ', h('input', { className: 'qqs-inp', style: Object.assign({ width: '90%' }, st), value: it.cwd || '', onChange: function (e) { upd(i, { cwd: e.target.value }) }, placeholder: '如 D:\\bots\\二号机' })),
1381
1464
  (function () {
@@ -1387,30 +1470,39 @@ window.__ModuleLoader__.load({
1387
1470
  return h('button', { key: w.dir, className: 'qqs-btn', style: { fontSize: 11, padding: '1px 8px', margin: '2px 4px 2px 0' }, onClick: function () { upd(i, { cwd: w.path }) } },
1388
1471
  (w.path.length > 46 ? w.path.slice(0, 43) + '…' : w.path) + ' (' + w.count + ')')
1389
1472
  }))
1390
- })())
1473
+ })())))
1391
1474
  }),
1392
- h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', margin: '8px 0' } },
1393
- h('button', { className: 'qqs-btn', onClick: addBlank }, '+ 添加账号(手动填凭据)'),
1394
- h('button', { className: 'qqs-btn', onClick: startBind, style: { borderColor: '#b197fc99', color: '#171a21' } }, '📱 扫码绑定新机器人(推荐)'),
1395
- h('span', { style: { flex: 1 } }),
1396
- h('button', { className: 'qqs-btn', onClick: save, disabled: busy === 'save' }, '保存账号配置'),
1397
- h('button', { className: 'qqs-btn', onClick: function () { load() } }, '重新读取')),
1475
+ h('div', { style: { position: 'relative', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', margin: '8px 0' } },
1476
+ h('button', { className: 'qqs-btn', onClick: function () { setAddMenu(!addMenu) }, style: { borderColor: '#b197fc99', color: '#171a21', fontWeight: 700 } }, addMenu ? '✕ 关闭' : '➕ 添加账号'),
1477
+ h('span', { style: { fontSize: 12, color: '#888' } }, '点加号选: 扫码绑定 或 手动填凭据'),
1478
+ (addMenu
1479
+ ? h('div', { style: { position: 'absolute', top: '100%', left: 0, marginTop: 6, zIndex: 60, minWidth: 280, background: '#fff', border: '1px solid #d9c6ff', borderRadius: 10, boxShadow: '0 10px 28px rgba(60,40,140,.18)', padding: 6, display: 'flex', flexDirection: 'column', gap: 4 } },
1480
+ h('button', { className: 'qqs-btn', style: { display: 'flex', alignItems: 'center', gap: 10, textAlign: 'left', padding: '8px 10px', justifyContent: 'flex-start', background: '#f3f0ff', borderColor: '#d0bfff' }, onClick: function () { setAddMenu(false); startBind() } },
1481
+ h('span', { style: { fontSize: 20 } }, '📱'),
1482
+ h('span', null,
1483
+ h('b', { style: { color: '#171a21' } }, '扫码绑定新机器人'),
1484
+ h('div', { style: { fontSize: 11, color: '#777' } }, '推荐 — 手机 QQ 扫码自动填凭据'))),
1485
+ h('button', { className: 'qqs-btn', style: { display: 'flex', alignItems: 'center', gap: 10, textAlign: 'left', padding: '8px 10px', justifyContent: 'flex-start' }, onClick: function () { setAddMenu(false); addBlank() } },
1486
+ h('span', { style: { fontSize: 20 } }, '✍️'),
1487
+ h('span', null,
1488
+ h('b', { style: { color: '#171a21' } }, '手动新建账号'),
1489
+ h('div', { style: { fontSize: 11, color: '#777' } }, '自己填写 AppID / AppSecret'))))
1490
+ : null)),
1398
1491
  h('div', { style: { ...sectionTitle, marginTop: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1399
1492
  '② Agent 预设(人格库)',
1400
1493
  h('button', { className: 'qqs-btn', style: { fontSize: 12, padding: '2px 10px', borderColor: '#2b8a3e99', color: '#171a21' }, onClick: function () { setCopy({ fromStandard: true, sourceId: 'standard' }); setCopyId(''); setCopyName('') } }, '➕ 从标准模式新建(带QQ工具)')),
1401
1494
  h('p', { style: { fontSize: 12, color: '#888' } }, PRESET_HINT),
1402
- (presets.filter(function (p) { return !p.hasChannelTools }).length > 0
1403
- ? h('p', { style: { fontSize: 12, color: '#e8590c', background: '#fff5f0', borderRadius: 6, padding: '6px 10px' } }, '💡 选一个预设点「复制」生成的副本会自动带上 QQ 工具(send_media/发图等), 之后就能在①账号里给机器人选用了。复制源无所谓, 只要是能跑的人格。')
1404
- : null),
1495
+ h('p', { style: { fontSize: 12, color: '#364fc7', background: '#edf2ff', borderRadius: 6, padding: '6px 10px' } }, '💡 QQ 工具(send_media/发图等)现在由机器人会话自动挂载(2026-09-06 起), 不再依赖预设是否带标记——任意预设都能在 QQ 用; 「从标准模式新建」的副本仍会自带标记供识别。'),
1405
1496
  h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(190px,1fr))', gap: 10, margin: '8px 0' } },
1406
1497
  presets.map(function (p) {
1407
1498
  return h('div', { key: p.id, style: { border: '1px solid #00000026', borderRadius: 10, padding: '10px 12px', background: 'rgba(255,255,255,.5)' } },
1408
1499
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
1409
1500
  h('span', { style: { fontWeight: 800, color: '#171a21' } }, p.name),
1410
- h('span', { style: { fontSize: 10, padding: '1px 6px', borderRadius: 8, color: '#fff', background: p.hasChannelTools ? '#2b8a3e' : '#e8590c' } }, p.hasChannelTools ? 'QQ可用' : '无QQ工具')),
1501
+ h('span', { style: { fontSize: 10, padding: '1px 6px', borderRadius: 8, color: '#fff', background: p.hasChannelTools ? '#2b8a3e' : '#5b9dff' } }, p.hasChannelTools ? 'QQ工具' : '会话自动挂载')),
1411
1502
  h('div', { style: { fontSize: 12, color: '#666', margin: '4px 0' } }, 'id=' + p.id),
1412
1503
  h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1413
1504
  h('button', { className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12 }, onClick: function () { setCopy({ sourceId: p.id }); setCopyId(''); setCopyName('') } }, '复制'),
1505
+ h('button', { className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12, borderColor: '#1971c299' }, onClick: function () { openPresetEditor(p) } }, '✏️ 人格'),
1414
1506
  h('button', { className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12 }, onClick: function () { openPreset(p.id) } }, '📂 打开文件夹')))
1415
1507
  })),
1416
1508
  copy ? h('div', { style: { position: 'fixed', inset: 0, background: '#000c', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: function () { if (!busy) setCopy(null) } },
@@ -1421,6 +1513,21 @@ window.__ModuleLoader__.load({
1421
1513
  h('div', { style: { textAlign: 'right', marginTop: 10 } },
1422
1514
  h('button', { className: 'qqs-btn', onClick: function () { if (!busy) setCopy(null) }, style: { marginRight: 8 } }, '取消'),
1423
1515
  h('button', { className: 'qqs-btn', onClick: doCopy, disabled: busy === 'copy' }, '生成新预设')))) : null,
1516
+ editP ? h('div', { style: { position: 'fixed', inset: 0, background: '#000c', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: function () { if (!editBusy) setEditP(null) } },
1517
+ h('div', { className: 'qqs-modal', style: { background: '#1f232b', color: '#e8e8e8', borderRadius: 12, padding: 14, maxWidth: 760, width: '94%', maxHeight: '88vh', display: 'flex', flexDirection: 'column' }, onClick: function (e) { e.stopPropagation() } },
1518
+ h('h3', { style: { marginTop: 0 } }, '✏️ 人格编辑器 — ' + editP.name + ' (' + editP.id + ')'),
1519
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', margin: '2px 0 6px', flexWrap: 'wrap' } },
1520
+ h('label', { style: { fontSize: 13 } }, '文件: '),
1521
+ h('select', { className: 'qqs-inp', style: { padding: '4px 8px', color: '#1f2329', maxWidth: 360 }, value: editFile, onChange: function (e) { pickEditFile(e.target.value) } },
1522
+ editFiles.map(function (fl) { return h('option', { key: fl.name, value: fl.name }, fl.name + ' (' + Math.max(1, Math.ceil(fl.size / 1024)) + 'KB)') })),
1523
+ h('span', { style: { fontSize: 12, color: editP.writable === false ? '#ffa94d' : '#7fd8a8' } }, editP.writable === false ? '🔒 只读(内置预设, 请先复制一份再改)' : (editP.writable ? '✅ 可编辑(你的副本)' : '⏳ 读取权限中…'))),
1524
+ h('p', { style: { fontSize: 12, color: '#9fb0c9', margin: '2px 0 6px' } }, '提示: agent.cordis.yml 里 persona 插件 config.text 就是人设正文; 直接整文件编辑, 保存后新会话生效。'),
1525
+ h('textarea', { className: 'qqs-inp', style: { width: '100%', boxSizing: 'border-box', flex: 1, minHeight: 300, fontFamily: 'ui-monospace,Consolas,monospace', fontSize: 12, lineHeight: 1.5, color: '#1f2329', whiteSpace: 'pre', resize: 'vertical', background: '#fff' }, value: editContent, readOnly: editP.writable === false, spellCheck: false, onChange: function (e) { setEditContent(e.target.value) } }),
1526
+ (editMsg ? h('p', { style: { fontSize: 12, color: '#ffa94d', margin: '4px 0' } }, editMsg) : null),
1527
+ h('div', { style: { textAlign: 'right', marginTop: 8 } },
1528
+ h('button', { className: 'qqs-btn', onClick: function () { if (!editBusy) setEditP(null) }, style: { marginRight: 8 } }, '关闭'),
1529
+ (editP.writable === false ? null : h('button', { className: 'qqs-btn', onClick: saveEditFile, disabled: editBusy || editP.writable === undefined, style: { borderColor: '#2f9e4499', color: '#171a21' } }, editBusy ? '保存中…' : '💾 保存')))))
1530
+ : null,
1424
1531
  bind ? h('div', { style: { position: 'fixed', inset: 0, background: '#000c', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' } },
1425
1532
  h('div', { className: 'qqs-modal', style: { background: '#22252e', color: '#e8e8e8', borderRadius: 12, padding: 14, maxWidth: 620, width: '94%' }, onClick: function (e) { e.stopPropagation() } },
1426
1533
  h('h3', { style: { marginTop: 0 } }, bind.status === 'error' ? '绑定失败' : '扫码绑定 QQ 机器人'),
@@ -1445,6 +1552,1250 @@ window.__ModuleLoader__.load({
1445
1552
  var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180deg,#ffffff,#f5f6f8);border:1px solid #d0d5dd;border-radius:9px;padding:5px 13px;cursor:pointer;transition:all .15s}.qqs-btn:hover{background:#ffffff;border-color:#7c6cf0;color:#7c6cf0}.qqs-btn:active{transform:translateY(1px)}.qqs-inp,.qqs-area{font:inherit;color:#1f2329;background:#ffffff;border:1px solid #d0d5dd;border-radius:9px;padding:5px 10px;outline:none;transition:border-color .15s}.qqs-inp::placeholder,.qqs-area::placeholder{color:#9aa1ab}.qqs-inp:focus,.qqs-area:focus{border-color:#7c6cf0;box-shadow:0 0 0 2px #7c6cf033}.qqs-cb{accent-color:#7c6cf0;width:15px;height:15px}.qqs-card{transition:box-shadow .15s,border-color .15s}.qqs-card:hover{box-shadow:0 2px 10px #0002}.qqs-panel{background:linear-gradient(180deg,#ffffff,#f7f8fa);border:1px solid #e2e5ea;border-radius:16px;padding:16px;box-shadow:0 10px 34px #0002}.qqs-toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;background:#f7f8fa;border:1px solid #e2e5ea;border-radius:12px;padding:8px 10px;margin-bottom:10px}.qqs-modal{background:#ffffff;color:#1f2329}"
1446
1553
  function ensureCss() { try { if (!document.getElementById('qqs-css')) { var st = document.createElement('style'); st.id = 'qqs-css'; st.textContent = QQS_CSS; document.head.appendChild(st) } } catch (e) {} }
1447
1554
 
1555
+ // ── 审批双通道 · Web 浮层(右下角小卡片) ──
1556
+ // 轮询 host 待办池(与 QQ 按钮/文本码共用同一 pending), 有点击即 POST 结算。
1557
+ // 原生 DOM 手写(不依赖 composer slot, 不与宿主原生审批面板打架), 2s 轮询够轻。
1558
+ var AP_FLOAT_CSS = "#qqs-ap-float{position:fixed;right:18px;bottom:84px;z-index:9999;width:min(340px,92vw);background:#fff;border:1px solid #d9c6ff;border-radius:14px;box-shadow:0 10px 34px rgba(80,40,140,.22);padding:0;overflow:hidden;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif}#qqs-ap-float .ap-h{display:flex;align-items:center;gap:8px;padding:10px 14px;background:linear-gradient(90deg,#7c6cf01f,#7c6cf008);font-size:13px;font-weight:700;color:#4a3a9f;border-bottom:1px solid #efe8ff}#qqs-ap-float .ap-b{padding:10px 14px;font-size:13px;color:#1f2329}#qqs-ap-float .ap-b .ap-t{font-weight:600;margin-bottom:4px;word-break:break-all}#qqs-ap-float .ap-b .ap-r{color:#666;font-size:12px;margin-bottom:6px;word-break:break-all;max-height:60px;overflow:auto}#qqs-ap-float .ap-a{display:flex;gap:8px;padding:0 14px 12px}#qqs-ap-float .ap-a button{font:inherit;font-size:13px;font-weight:700;padding:7px 0;border-radius:9px;cursor:pointer;border:1px solid transparent;transition:opacity .15s}#qqs-ap-float .ap-a button:disabled{opacity:.5;cursor:default}#qqs-ap-float .ap-ok,.qqs-ap-float .ap-ok{background:#e6f7ec;color:#187a3d;border-color:#b8e6c8!important;flex:1}#qqs-ap-float .ap-no{background:#fdeeee;color:#c23131;border-color:#f3c4c4!important;flex:1}#qqs-ap-float .qs-opt{background:#f1ecff;color:#4a3a9f;border-color:#d9c6ff!important;text-align:left;padding:7px 12px;margin:2px 0;border-radius:9px;width:100%}#qqs-ap-float .ap-c{position:absolute;top:6px;right:10px;font-size:16px;color:#999;cursor:pointer;line-height:1}#qqs-ap-float .ap-done{color:#888;font-size:12px;padding:4px 14px 10px}";
1559
+ function ensureFloatCss() { try { if (!document.getElementById('qqs-ap-float-css')) { var st = document.createElement('style'); st.id = 'qqs-ap-float-css'; st.textContent = AP_FLOAT_CSS; document.head.appendChild(st) } } catch (e) {} }
1560
+ function fmtDeadline(ts) {
1561
+ var left = Math.max(0, Math.ceil((ts - Date.now()) / 1000))
1562
+ return left > 60 ? Math.ceil(left / 60) + ' 分钟' : left + ' 秒'
1563
+ }
1564
+ function startApprovalFloat() {
1565
+ try { ensureFloatCss() } catch (e) { return }
1566
+ var lastKey = ''
1567
+ var busy = false
1568
+ var hidden = false
1569
+ var root = null
1570
+ var itemType = '' // 'ap' 审批 | 'qs' 提问
1571
+
1572
+ function show(kind, titleHtml, bodyHtml, actionHtml) {
1573
+ if (!root) {
1574
+ root = document.createElement('div')
1575
+ root.id = 'qqs-ap-float'
1576
+ document.body.appendChild(root)
1577
+ }
1578
+ root.style.display = hidden ? 'none' : 'block'
1579
+ itemType = kind
1580
+ lastKey = kind + ':' + keyOf(kind)
1581
+ root.innerHTML =
1582
+ '<div class="ap-h"><span>' + titleHtml + '</span><span style="flex:1"></span><span class="ap-c" title="关闭">✕</span></div>'
1583
+ + bodyHtml + actionHtml
1584
+ + '<div class="ap-done" style="display:none">已处理 ✓</div>'
1585
+ root.querySelector('.ap-c').onclick = function () { root.style.display = 'none'; hidden = true }
1586
+ }
1587
+ function keyOf(kind) {
1588
+ if (kind === 'ap') return (curAp && (curAp.ns + ':' + curAp.code)) || ''
1589
+ return (curQs && (curQs.ns + ':' + curQs.key)) || ''
1590
+ }
1591
+ function doneMsg(m) {
1592
+ if (!root) return
1593
+ var a = root.querySelector('.ap-a'); if (a) a.style.display = 'none'
1594
+ var d = root.querySelector('.ap-done')
1595
+ if (d) { d.style.display = 'block'; d.textContent = m || '已处理 ✓' }
1596
+ setTimeout(function () { if (root) root.style.display = 'none' }, 1800)
1597
+ }
1598
+
1599
+ // ── 审批卡片渲染 ──
1600
+ var curAp = null
1601
+ function renderAp(list) {
1602
+ if (itemType && itemType !== 'ap' && !list.length) return
1603
+ var ap = list[0]
1604
+ if (!ap) { if (itemType === 'ap') { if (root) root.style.display = 'none' }; curAp = null; return }
1605
+ curAp = ap
1606
+ var k = ap.ns + ':' + ap.code
1607
+ if (itemType === 'ap' && k === lastKey) return
1608
+ hidden = false
1609
+ show('ap', '⚠️ DSH 权限申请',
1610
+ '<div class="ap-b"><div class="ap-t">工具:' + esc(ap.toolName || '?')
1611
+ + '</div>' + (ap.reason ? '<div class="ap-r">' + esc(ap.reason) + '</div>' : '')
1612
+ + '<div style="font-size:11px;color:#999">' + esc(ap.ownerHint || '') + ' · ' + fmtDeadline(ap.deadlineAt) + ' 后自动拒绝 · 共 ' + list.length + ' 条</div></div>',
1613
+ '<div class="ap-a">'
1614
+ + '<button class="ap-ok" data-act="allow">✅ 允许</button>'
1615
+ + '<button class="ap-no" data-act="deny">❌ 拒绝</button>'
1616
+ + '</div>')
1617
+ bindApButtons(ap)
1618
+ }
1619
+ function bindApButtons(ap) {
1620
+ if (!root) return
1621
+ root.querySelectorAll('.ap-a button').forEach(function (btn) {
1622
+ btn.onclick = function () {
1623
+ if (busy) return
1624
+ busy = true
1625
+ var act = btn.getAttribute('data-act')
1626
+ btn.disabled = true
1627
+ fetch('/api/qqbot-settings/approval/decide', {
1628
+ method: 'POST', headers: { 'content-type': 'application/json' },
1629
+ body: JSON.stringify({ code: ap.code, act: act }),
1630
+ }).then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
1631
+ busy = false; lastKey = ''
1632
+ doneMsg((d && d.msg) || (act === 'allow' ? '已允许 ✓' : '已拒绝 ✓'))
1633
+ }).catch(function () { busy = false; btn.disabled = false })
1634
+ }
1635
+ })
1636
+ }
1637
+
1638
+ // ── 提问卡片渲染(选项逐个列出, 点击即答) ──
1639
+ var curQs = null
1640
+ function renderQs(list) {
1641
+ if (itemType && itemType !== 'qs' && !list.length) return
1642
+ var q = list[0]
1643
+ if (!q) { if (itemType === 'qs') { if (root) root.style.display = 'none' }; curQs = null; return }
1644
+ curQs = q
1645
+ var k = q.ns + ':' + q.key
1646
+ if (itemType === 'qs' && k === lastKey) return
1647
+ hidden = false
1648
+ var optBtns = (q.options || []).map(function (o, i) {
1649
+ return '<button class="qs-opt" data-i="' + i + '">' + esc(String(o.label || '').slice(0, 40)) + '</button>'
1650
+ }).join('')
1651
+ show('qs', '❓ 远程提问',
1652
+ '<div class="ap-b">' + (q.header ? '<div class="ap-t">' + esc(q.header) + '</div>' : '')
1653
+ + '<div class="ap-t">' + esc(q.question || '') + '</div>'
1654
+ + (q.detail ? '<div class="ap-r">' + esc(q.detail) + '</div>' : '')
1655
+ + '<div style="font-size:11px;color:#999">' + fmtDeadline(q.deadlineAt) + ' 后失效 · 共 ' + list.length + ' 条</div></div>',
1656
+ '<div class="ap-a" style="flex-direction:column;align-items:stretch">' + optBtns + '</div>')
1657
+ bindQsButtons(q)
1658
+ }
1659
+ function bindQsButtons(q) {
1660
+ if (!root) return
1661
+ root.querySelectorAll('.qs-opt').forEach(function (btn) {
1662
+ btn.onclick = function () {
1663
+ if (busy) return
1664
+ busy = true
1665
+ var i = Number(btn.getAttribute('data-i'))
1666
+ btn.disabled = true
1667
+ fetch('/api/qqbot-settings/questions/decide', {
1668
+ method: 'POST', headers: { 'content-type': 'application/json' },
1669
+ body: JSON.stringify({ key: q.key, optIdx: i }),
1670
+ }).then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
1671
+ busy = false; lastKey = ''
1672
+ doneMsg((d && d.msg) || '已选择 ✓')
1673
+ }).catch(function () { busy = false; btn.disabled = false })
1674
+ }
1675
+ })
1676
+ }
1677
+
1678
+ function poll() {
1679
+ if (busy) return
1680
+ try {
1681
+ fetch('/api/qqbot-settings/approval/pending').then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
1682
+ if (d && Array.isArray(d.pending)) renderAp(d.pending)
1683
+ else if (itemType === 'ap' && d && !d.ok) { if (root) root.style.display = 'none'; itemType = '' }
1684
+ }).catch(function () {})
1685
+ fetch('/api/qqbot-settings/questions/pending').then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
1686
+ if (d && Array.isArray(d.pending)) renderQs(d.pending)
1687
+ else if (itemType === 'qs' && d && !d.ok) { if (root) root.style.display = 'none'; itemType = '' }
1688
+ }).catch(function () {})
1689
+ } catch (e) {}
1690
+ }
1691
+ poll()
1692
+ setInterval(poll, 2000)
1693
+ }
1694
+
1695
+ // ══ 群管理悬浮球 dock(可拖拽; 点开=群管理悬浮台; 关闭收回成球) ══
1696
+ // 与审批浮层同款: 原生 DOM 手写挂 body, 全页面可用, 不与宿主 slots 打架。
1697
+ // 三类提示的分工(主人 2026-09-06 定):
1698
+ // · 审批/提问 → 打断式弹出(#qqs-ap-float 保持)
1699
+ // · 入群申请 → 悬浮球上安静红点(不弹窗)
1700
+ // · 群管理(发消息/审批入群/禁言) → 悬浮球点开成操作台
1701
+ var DOCK_CSS = "#qqs-dock-wrap{position:fixed;right:18px;bottom:190px;z-index:9997;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif}#qqs-dock-ball{width:52px;height:52px;border-radius:50%;background:linear-gradient(160deg,#7c6cf0,#5b4fd8);color:#fff;font-size:24px;line-height:52px;text-align:center;cursor:pointer;box-shadow:0 6px 20px rgba(90,70,220,.4);user-select:none;transition:transform .12s,box-shadow .12s;position:relative}#qqs-dock-ball:hover{transform:scale(1.06)}#qqs-dock-badge{position:absolute;top:-4px;right:-4px;min-width:18px;height:18px;border-radius:9px;background:#ff4d4f;color:#fff;font-size:11px;font-weight:700;line-height:18px;padding:0 4px;box-sizing:border-box;text-align:center;display:none}#qqs-dock-panel{position:fixed;right:18px;bottom:190px;z-index:9998;width:min(720px,94vw);max-height:68vh;display:none;flex-direction:column;background:#fff;border:1px solid #d9c6ff;border-radius:16px;box-shadow:0 12px 40px rgba(60,40,140,.25);overflow:hidden;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif}#qqs-dock-panel .dk-h{display:flex;align-items:center;gap:8px;padding:10px 14px;background:linear-gradient(90deg,#7c6cf01f,#7c6cf008);font-size:14px;font-weight:700;color:#4a3a9f;border-bottom:1px solid #efe8ff}#qqs-dock-panel .dk-b{padding:10px 14px;overflow:auto;font-size:13px;color:#1f2329}#qqs-dock-panel .dk-tab{display:flex;gap:4px;border-bottom:1px solid #eee;margin-bottom:10px}#qqs-dock-panel .dk-tab button{font:inherit;font-size:13px;padding:6px 14px;border:none;background:none;cursor:pointer;color:#666;border-bottom:2px solid transparent}#qqs-dock-panel .dk-tab button.on{color:#4a3a9f;font-weight:700;border-bottom-color:#7c6cf0}#qqs-dock-panel select.qqs-sel,#qqs-dock-panel input.qqs-txt,#qqs-dock-panel textarea.qqs-txt{font:inherit;color:#1f2329;background:#fff;border:1px solid #d0d5dd;border-radius:8px;padding:5px 8px;outline:none}#qqs-dock-panel .dk-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0}#qqs-dock-panel .dk-btn{font:inherit;font-size:13px;padding:5px 12px;border-radius:8px;cursor:pointer;border:1px solid #d9c6ff;background:#f1ecff;color:#4a3a9f}#qqs-dock-panel .dk-btn.ok{background:#e6f7ec;color:#187a3d;border-color:#b8e6c8}#qqs-dock-panel .dk-btn.no{background:#fdeeee;color:#c23131;border-color:#f3c4c4}#qqs-dock-panel .dk-btn:disabled{opacity:.5;cursor:default}#qqs-dock-panel .dk-msg{color:#888;font-size:12px;padding:2px 0}#qqs-dock-panel .dk-list{max-height:34vh;overflow:auto;border:1px solid #f0ecff;border-radius:10px;padding:4px}#qqs-dock-panel .dk-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #f5f2ff;flex-wrap:wrap;font-size:13px}#qqs-dock-panel .dk-item:last-child{border-bottom:none}#qqs-dock-panel .dk-empty{color:#aaa;text-align:center;padding:18px 0;font-size:12px}"
1702
+ // ── 💬 聊天视图样式(dock 追加段, 2026-09-07): QQ 风格气泡, 群友左(bot)右 ──
1703
+ var DOCK_CSS2 = "#qqs-dock-panel .dk-chat-head{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:0 0 6px}#qqs-dock-panel .dk-chat-box{overflow-y:auto;overscroll-behavior:contain;background:#f5f6f8;border:1px solid #e6e8ec;border-radius:10px;padding:10px 12px;box-sizing:border-box;height:min(36vh,300px);min-height:140px;scroll-behavior:auto}#qqs-dock-panel .dk-chat-box::-webkit-scrollbar{width:6px}#qqs-dock-panel .dk-chat-box::-webkit-scrollbar-thumb{background:#d3d7dd;border-radius:3px}#qqs-dock-panel .dk-crow{display:flex;gap:8px;align-items:flex-start;margin:0 0 12px}#qqs-dock-panel .dk-crow.out{flex-direction:row-reverse}#qqs-dock-panel .dk-ava{width:32px;height:32px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;color:#fff;overflow:hidden;user-select:none;background:linear-gradient(150deg,#8fb3e8,#5f8fd9)}#qqs-dock-panel .dk-crow.out .dk-ava{background:linear-gradient(150deg,#5ec7f2,#3b8fe0)}#qqs-dock-panel .dk-cmain{display:flex;flex-direction:column;max-width:calc(100% - 40px);min-width:0}#qqs-dock-panel .dk-crow.in .dk-cmain{align-items:flex-start}#qqs-dock-panel .dk-crow.out .dk-cmain{align-items:flex-end}#qqs-dock-panel .dk-cmeta{font-size:11px;color:#9aa0a8;margin:0 6px 2px;max-width:100%;display:flex;align-items:center;gap:5px;flex-wrap:wrap}#qqs-dock-panel .dk-crow.out .dk-cmeta{flex-direction:row-reverse}#qqs-dock-panel .dk-cbubble{padding:7px 11px;font-size:13px;line-height:1.55;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;box-shadow:0 1px 2px rgba(20,30,60,.06);max-width:100%}#qqs-dock-panel .dk-crow.in .dk-cbubble{background:#fff;border:1px solid #e3e6ea;color:#1f2329;border-radius:3px 10px 10px 10px}#qqs-dock-panel .dk-crow.out .dk-cbubble{background:linear-gradient(180deg,#69a6ff,#3d7df5);color:#fff;border-radius:10px 3px 10px 10px}#qqs-dock-panel .dk-img{display:block;max-width:min(230px,52vw);max-height:200px;border-radius:6px;margin:0 0 3px;object-fit:cover;cursor:zoom-in}#qqs-dock-panel .dk-audio{display:block;max-width:min(260px,60vw);width:100%;height:34px;margin:0 0 2px}#qqs-dock-panel .dk-file{display:inline-flex;align-items:center;gap:5px;max-width:100%;padding:6px 12px;border-radius:8px;background:#f0f6ff;border:1px solid #cfe0fa;color:#2b6bd8;font-size:13px;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#qqs-dock-panel .dk-file:hover{background:#e2edff}#qqs-dock-panel .dk-video{display:block;max-width:min(260px,60vw);max-height:180px;border-radius:6px;margin:0 0 2px}#qqs-dock-panel .dk-ctag{display:inline-block;font-size:10px;color:#5b8ff0;background:#eaf2ff;border:1px solid #d4e3fd;border-radius:8px;padding:0 6px}#qqs-dock-panel .dk-chat-top{text-align:center;color:#b0b4bb;font-size:11px;padding:2px 0 6px;user-select:none}#qqs-dock-panel .dk-chat-bottom{text-align:center;color:#c3c7cd;font-size:11px;padding:6px 0 0}#qqs-dock-panel .dk-composer{margin-top:8px;border:1px solid #e3e6ea;border-radius:10px;background:#fff;overflow:hidden}#qqs-dock-panel .dk-composer textarea{width:100%;box-sizing:border-box;border:none;outline:none;resize:none;font:inherit;font-size:13px;color:#1f2329;background:transparent;padding:8px 10px 4px;line-height:1.5;max-height:120px}#qqs-dock-panel .dk-cbar{display:flex;align-items:center;gap:4px;padding:4px 8px 6px;flex-wrap:wrap}#qqs-dock-panel .dk-cbar .dk-btn{padding:3px 10px;font-size:12px;border-radius:7px}#qqs-dock-panel .dk-cbar .dk-send{background:linear-gradient(180deg,#69a6ff,#3d7df5);color:#fff;border:none;border-radius:8px;padding:5px 18px;font-size:13px;font-weight:600;cursor:pointer}#qqs-dock-panel .dk-cbar .dk-send:disabled{opacity:.5;cursor:default}#qqs-lightbox{position:fixed;inset:0;z-index:2147483000;background:rgba(8,10,18,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out}#qqs-lightbox img{max-width:92vw;max-height:92vh;border-radius:8px;box-shadow:0 10px 60px rgba(0,0,0,.6)}#qqs-lightbox .lb-x{position:fixed;right:16px;top:10px;color:#fff;font-size:30px;cursor:pointer;line-height:1;padding:6px}#qqs-dock-panel.dk-full{left:0!important;top:0!important;right:0!important;bottom:0!important;width:100vw!important;max-width:100vw!important;height:100vh!important;max-height:100vh!important;border-radius:0;z-index:2147482000;display:flex;flex-direction:column}#qqs-dock-panel.dk-full .dk-h,#qqs-dock-panel.dk-full .dk-detect{flex:none}#qqs-dock-panel.dk-full .dk-b{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding:8px 14px 6px}#qqs-dock-panel.dk-full .dk-chat-wrap{display:flex;flex-direction:column;flex:1;min-height:0}#qqs-dock-panel.dk-full .dk-chat-head{flex:none}#qqs-dock-panel.dk-full .dk-chat-box{flex:1;height:auto!important;min-height:0!important;max-height:none!important;overflow-y:auto;overscroll-behavior:contain}#qqs-dock-panel.dk-full .dk-composer{flex:none;margin-top:6px}"
1704
+ function ensureDockCss2() { try { if (!document.getElementById('qqs-dock-css2')) { var st = document.createElement('style'); st.id = 'qqs-dock-css2'; st.textContent = DOCK_CSS2; document.head.appendChild(st) } } catch (e) {} };
1705
+ function ensureDockCss() { try { if (!document.getElementById('qqs-dock-css')) { var st = document.createElement('style'); st.id = 'qqs-dock-css'; st.textContent = DOCK_CSS; document.head.appendChild(st) } } catch (e) {} }
1706
+ function startQqDock(sessionsSvc) {
1707
+ try { ensureDockCss() } catch (e) { return }
1708
+ try { ensureDockCss2() } catch (e) {}
1709
+ // 幂等(2026-09-07): 宿主重启/client 重连若触发重复初始化, 先移除旧 dock 再建, 保证始终只有一个悬浮球
1710
+ var oldWrap = document.getElementById('qqs-dock-wrap')
1711
+ if (oldWrap) { try { oldWrap.remove() } catch (e) { if (oldWrap.parentNode) oldWrap.parentNode.removeChild(oldWrap) } }
1712
+ // 位置记忆(可拖拽)
1713
+ var pos = null; try { var raw = localStorage.getItem('qqs-dock-pos'); if (raw) pos = JSON.parse(raw) } catch (e) {}
1714
+ var wrap = document.createElement('div')
1715
+ wrap.id = 'qqs-dock-wrap'
1716
+ wrap.innerHTML = '<div id="qqs-dock-ball" title="QQ 群管理(拖拽移动, 点开面板)">🛡<span id="qqs-dock-badge">0</span></div>'
1717
+ document.body.appendChild(wrap)
1718
+ var ball = wrap.querySelector('#qqs-dock-ball')
1719
+ var badge = wrap.querySelector('#qqs-dock-badge')
1720
+ var panel = null
1721
+ var open = false
1722
+ if (pos) { wrap.style.left = pos.x + 'px'; wrap.style.top = pos.y + 'px'; wrap.style.right = 'auto'; wrap.style.bottom = 'auto' }
1723
+ // 拖拽(球上按住移动; 单击与拖拽区分)
1724
+ var dragState = null
1725
+ ball.addEventListener('mousedown', function (e) {
1726
+ dragState = { sx: e.clientX, sy: e.clientY, ox: wrap.offsetLeft, oy: wrap.offsetTop, moved: false }
1727
+ e.preventDefault()
1728
+ })
1729
+ document.addEventListener('mousemove', function (e) {
1730
+ if (!dragState) return
1731
+ var dx = e.clientX - dragState.sx, dy = e.clientY - dragState.sy
1732
+ if (Math.abs(dx) + Math.abs(dy) > 3) dragState.moved = true
1733
+ if (!dragState.moved) return
1734
+ wrap.style.left = Math.max(0, Math.min(window.innerWidth - 60, dragState.ox + dx)) + 'px'
1735
+ wrap.style.top = Math.max(0, Math.min(window.innerHeight - 60, dragState.oy + dy)) + 'px'
1736
+ wrap.style.right = 'auto'; wrap.style.bottom = 'auto'
1737
+ syncPanelPos()
1738
+ })
1739
+ document.addEventListener('mouseup', function (e) {
1740
+ if (!dragState) return
1741
+ var moved = dragState.moved
1742
+ dragState = null
1743
+ if (moved) {
1744
+ try { localStorage.setItem('qqs-dock-pos', JSON.stringify({ x: wrap.offsetLeft, y: wrap.offsetTop })) } catch (err) {}
1745
+ }
1746
+ if (moved && e.target === ball) return
1747
+ })
1748
+ function syncPanelPos() {
1749
+ if (!panel || !open) return
1750
+ layoutPanel()
1751
+ }
1752
+ // ── 面板自适应定位: 按悬浮球屏幕位置翻转展开方向, 边缘 clamp 保证完整可见 ──
1753
+ var lastBallRect = null
1754
+ var manualPanelPos = null // 手动拖过面板后固定位置(不再自动贴球)
1755
+ function layoutPanel() {
1756
+ if (!panel || !open) return
1757
+ var vw = window.innerWidth, vh = window.innerHeight
1758
+ var pad = 10
1759
+ // 手动拖拽固定位置: 保持并 clamp
1760
+ if (manualPanelPos) {
1761
+ var pw2 = panel.offsetWidth || Math.min(720, vw * 0.94)
1762
+ var ph2 = panel.offsetHeight || Math.round(vh * 0.68)
1763
+ panel.style.left = Math.max(pad, Math.min(manualPanelPos.x, vw - pw2 - pad)) + 'px'
1764
+ panel.style.top = Math.max(pad, Math.min(manualPanelPos.y, vh - ph2 - pad)) + 'px'
1765
+ panel.style.right = 'auto'
1766
+ panel.style.bottom = 'auto'
1767
+ panel.style.maxHeight = Math.round(vh - pad * 2) + 'px'
1768
+ return
1769
+ }
1770
+ if (!lastBallRect) { lastBallRect = { left: vw - 70, top: vh - 80, right: vw - 18, bottom: vh - 28, width: 52, height: 52 } }
1771
+ var pw = panel.offsetWidth || Math.min(720, vw * 0.94)
1772
+ var ph = Math.min(panel.offsetHeight || Math.round(vh * 0.68), vh - pad * 2)
1773
+ // 水平: 右侧放不下 → 向左展开; 左侧放不下 → 向右展开; 再整体 clamp
1774
+ var left
1775
+ if (lastBallRect.right + pad + pw <= vw - pad) left = lastBallRect.right + pad
1776
+ else if (lastBallRect.left - pad - pw >= pad) left = lastBallRect.left - pad - pw
1777
+ else left = (lastBallRect.left + lastBallRect.width / 2) > vw / 2 ? vw - pw - pad : pad
1778
+ left = Math.max(pad, Math.min(left, vw - pw - pad))
1779
+ // 垂直: 下方放不下 → 向上展开
1780
+ var top
1781
+ if (lastBallRect.bottom + pad + ph <= vh - pad) top = lastBallRect.bottom + pad
1782
+ else if (lastBallRect.top - pad - ph >= pad) top = lastBallRect.top - pad - ph
1783
+ else top = vh - ph - pad
1784
+ top = Math.max(pad, Math.min(top, vh - ph - pad))
1785
+ panel.style.left = Math.round(left) + 'px'
1786
+ panel.style.top = Math.round(top) + 'px'
1787
+ panel.style.right = 'auto'
1788
+ panel.style.bottom = 'auto'
1789
+ // 面板超高时内部滚动, 头/关闭按钮始终可见
1790
+ panel.style.maxHeight = Math.round(vh - pad * 2) + 'px'
1791
+ }
1792
+ // 展开态拖拽: 按住面板头部(.dk-h 非控件区)移动整个 dock 面板
1793
+ function bindPanelDrag() {
1794
+ if (!panel || !open) return
1795
+ var h = panel.querySelector('.dk-h')
1796
+ if (!h) return
1797
+ h.onmousedown = function (e) {
1798
+ if (e.button !== 0) return
1799
+ var t = e.target
1800
+ if (t && t.closest && t.closest('button,select,input,textarea,a,.qqs-sel,.qqs-txt')) return
1801
+ e.preventDefault()
1802
+ var sx = e.clientX, sy = e.clientY
1803
+ var ox = panel.offsetLeft, oy = panel.offsetTop
1804
+ var moved = false
1805
+ var mm = function (ev) {
1806
+ var dx = ev.clientX - sx, dy = ev.clientY - sy
1807
+ if (Math.abs(dx) + Math.abs(dy) > 2) moved = true
1808
+ panel.style.left = (ox + dx) + 'px'
1809
+ panel.style.top = (oy + dy) + 'px'
1810
+ panel.style.right = 'auto'
1811
+ panel.style.bottom = 'auto'
1812
+ }
1813
+ var mu = function () {
1814
+ document.removeEventListener('mousemove', mm)
1815
+ document.removeEventListener('mouseup', mu)
1816
+ if (moved) manualPanelPos = { x: panel.offsetLeft, y: panel.offsetTop }
1817
+ }
1818
+ document.addEventListener('mousemove', mm)
1819
+ document.addEventListener('mouseup', mu)
1820
+ }
1821
+ // 双击头部 → 取消手动固定, 恢复自动贴球定位
1822
+ h.ondblclick = function (e) {
1823
+ var t = e.target
1824
+ if (t && t.closest && t.closest('button,select,input,textarea,a')) return
1825
+ manualPanelPos = null
1826
+ layoutPanel()
1827
+ }
1828
+ }
1829
+ function closePanel() {
1830
+ open = false
1831
+ if (panel) { panel.style.display = 'none'; panel.innerHTML = '' }
1832
+ ball.style.display = 'block'
1833
+ }
1834
+ function togglePanel() {
1835
+ if (dragState && dragState.moved) return
1836
+ if (open) { closePanel(); return }
1837
+ if (!panel) {
1838
+ panel = document.createElement('div')
1839
+ panel.id = 'qqs-dock-panel'
1840
+ document.body.appendChild(panel)
1841
+ }
1842
+ open = true
1843
+ // 先量球位置(隐藏前), 再隐藏球
1844
+ var wr = wrap.getBoundingClientRect()
1845
+ lastBallRect = { left: wr.left, top: wr.top, right: wr.right, bottom: wr.bottom, width: wr.width, height: wr.height }
1846
+ ball.style.display = 'none'
1847
+ panel.style.display = 'flex'
1848
+ panel.style.left = 'auto'; panel.style.top = 'auto'; panel.style.right = 'auto'; panel.style.bottom = 'auto'
1849
+ renderPanel()
1850
+ // 等一帧内容渲染完再定位(offsetWidth/offsetHeight 才准确)
1851
+ setTimeout(layoutPanel, 0)
1852
+ }
1853
+ ball.addEventListener('click', togglePanel)
1854
+ // Esc 或点击面板外 → 收回成球
1855
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && open) closePanel() })
1856
+ document.addEventListener('mousedown', function (e) {
1857
+ if (!open) return
1858
+ // 灯箱(#qqs-lightbox)是浮在 dock 外的全屏层: 点它(关闭放大)不能把 dock 也收走
1859
+ if (e.target && e.target.closest && e.target.closest('#qqs-lightbox')) return
1860
+ if (panel && (panel.contains(e.target) || panel === e.target)) return
1861
+ if (wrap && (wrap.contains(e.target) || wrap === e.target)) return
1862
+ closePanel()
1863
+ })
1864
+ function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
1865
+ function api(path, q) {
1866
+ var url = '/api/qqbot-settings/' + path + (q ? '?' + q : '')
1867
+ return fetch(url).then(function (r) { return r.json().catch(function () { return null }) }).catch(function () { return null })
1868
+ }
1869
+ function apiPost(path, body) {
1870
+ return fetch('/api/qqbot-settings/' + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) })
1871
+ .then(function (r) { return r.json().catch(function () { return null }) }).catch(function () { return null })
1872
+ }
1873
+ // ── 红点: 入群申请待审汇总(各实例 pending 数之和, 不弹窗) ──
1874
+ function refreshBadge() {
1875
+ api('group/join-summary').then(function (d) {
1876
+ if (!d || !d.ok || !Array.isArray(d.items)) return
1877
+ var n = 0
1878
+ d.items.forEach(function (it) { n += (it.pending || 0) })
1879
+ if (n > 0) { badge.style.display = 'block'; badge.textContent = n > 99 ? '99+' : String(n) }
1880
+ else badge.style.display = 'none'
1881
+ if (panel && open) { var b = panel.querySelector('.dk-join-badge'); if (b) b.style.display = n > 0 ? 'inline-block' : 'none'; if (b) b.textContent = n > 99 ? '99+' : String(n) }
1882
+ })
1883
+ }
1884
+ refreshBadge()
1885
+ setInterval(refreshBadge, 20000)
1886
+
1887
+ // ── 面板状态(每个实例独立保存, 切回不丢) ──
1888
+ var state = { ns: '', accts: [], gid: '', groups: [], tab: 'chat', sendScope: 'group', sendTo: '', sendName: '', sendText: '', insertCtx: true, targetQ: '', c2cs: [], joins: null, mutes: null, members: null, muteSecs: '60', bindGid: '', bindName: '', msg: '', busy: '', wantPeer: null, lookedUp: false, detected: null, detectedHit: null, chatItems: [], chatMore: false, chatBusy: '', chatErr: '', chatOldest: 0, chatText: '', chatIns: true }
1889
+ var chatFlash = '' // 发送结果/错误提示(短时展示, 不被列表计数覆盖)
1890
+ function loadAccts() {
1891
+ api('accounts').then(function (d) {
1892
+ var list = (d && Array.isArray(d.instances) ? d.instances : []).filter(function (a) { return !a.disabled })
1893
+ state.accts = list
1894
+ if (list.length && !state.ns) {
1895
+ // 优先跟随 web 当前会话绑定的 QQ 目标(session-lookup); 未命中才回落到第一个实例
1896
+ reDetectAndSelect()
1897
+ } else if (list.length && state.ns) { /* 保持 */ paintHead(); paintDetect() } else { state.ns = ''; paintHead(); paintDetect() }
1898
+ })
1899
+ }
1900
+ // 读 web 当前会话(sessionId)并反查它是否挂在某个 qqbot 的群/私聊上
1901
+ // 权威源优先: ctx.sessions.list.getSnapshot().current(宿主官方, agent id === session id);
1902
+ // 兜底: localStorage 'dsh.sessions.current'(宿主持久化镜像, 每次切换实时写)。
1903
+ function readCurrentSessionId() {
1904
+ try {
1905
+ if (sessionsSvc && sessionsSvc.list && typeof sessionsSvc.list.getSnapshot === 'function') {
1906
+ var snap = sessionsSvc.list.getSnapshot()
1907
+ if (snap && snap.current) { state.detected = { src: 'sessions.list', sid: snap.current, title: (snap.byId && snap.byId[snap.current] && (snap.byId[snap.current].displayTitle || snap.byId[snap.current].title)) || '' }; return snap.current }
1908
+ }
1909
+ } catch (e) {}
1910
+ try { var raw = localStorage.getItem('dsh.sessions.current'); if (raw) { var o = JSON.parse(raw); if (o && o.sessionId) { state.detected = { src: 'localStorage', sid: o.sessionId, title: '' }; return o.sessionId } } } catch (e) {}
1911
+ state.detected = { src: 'none', sid: '', title: '' }
1912
+ return ''
1913
+ }
1914
+ function lookupCurrentSession(cb) {
1915
+ var sid = readCurrentSessionId()
1916
+ if (!sid) { cb(null); return }
1917
+ api('session-lookup', 'sessionId=' + encodeURIComponent(sid)).then(function (d) {
1918
+ if (d && d.ok && Array.isArray(d.hits) && d.hits.length) {
1919
+ state.detectedHit = d.hits[0]
1920
+ cb(d.hits[0])
1921
+ return
1922
+ }
1923
+ state.detectedHit = null
1924
+ cb(null)
1925
+ }).catch(function () { state.detectedHit = null; cb(null) })
1926
+ }
1927
+ function curDataDir() {
1928
+ var ac = null; state.accts.forEach(function (a) { if (a.ns === state.ns) ac = a })
1929
+ return ac ? (ac.dataDir || '') : ''
1930
+ }
1931
+ function refreshAll() {
1932
+ loadGroups(); loadC2cs()
1933
+ }
1934
+ function loadGroups() {
1935
+ state.groups = []
1936
+ var q = state.ns ? 'ns=' + encodeURIComponent(state.ns) : ''
1937
+ api('group/accounts', q).then(function (d) {
1938
+ state.groups = (d && Array.isArray(d.groups) ? d.groups : []).map(function (g) { return { gid: g.gid, name: g.name || '', from: g.from || '' } })
1939
+ // 期望目标(wantPeer)命中则选中它, 否则回落第一个
1940
+ var wantGid = (state.wantPeer && state.wantPeer.scope === 'group') ? state.wantPeer.peerId : ''
1941
+ if (state.groups.some(function (g) { return g.gid === state.gid })) { /* 保持现选 */ }
1942
+ else if (wantGid && state.groups.some(function (g) { return g.gid === wantGid })) state.gid = wantGid
1943
+ else if (state.groups.length) state.gid = state.groups[0].gid
1944
+ else state.gid = ''
1945
+ paintBody()
1946
+ })
1947
+ }
1948
+ function loadC2cs() {
1949
+ var dd = curDataDir()
1950
+ state.c2cs = []
1951
+ if (!dd) { paintBody(); return }
1952
+ // 与定时任务同款三源合并: 台账 + 本地成员表补名(不露裸 id)
1953
+ var qNs = state.ns ? ('?ns=' + encodeURIComponent(state.ns)) : ''
1954
+ var qDD = dd ? ('?dataDir=' + encodeURIComponent(dd)) : ''
1955
+ Promise.all([
1956
+ api('known-chats', qDD.replace('?', '')),
1957
+ api('group/members_local', qNs.replace('?', '')),
1958
+ ]).then(function (rs) {
1959
+ var known = ((rs[0] && rs[0].chats) || [])
1960
+ var nameByMid = {}
1961
+ ;((rs[1] && rs[1].members) || []).forEach(function (m) {
1962
+ if (m.mid && m.name) {
1963
+ var old = nameByMid[m.mid]
1964
+ if (!old || m.lastSeen > old.ts) nameByMid[m.mid] = { name: m.name, ts: m.lastSeen || 0 }
1965
+ }
1966
+ })
1967
+ var c2c = []
1968
+ var seen = {}
1969
+ known.forEach(function (c) {
1970
+ if (c.scope !== 'c2c' || !c.id || seen[c.id]) return
1971
+ seen[c.id] = 1
1972
+ var nm = c.name || (nameByMid[c.id] && nameByMid[c.id].name) || ''
1973
+ c2c.push({ id: c.id, name: nm, lastSeen: c.lastSeen || 0, count: c.count || 0 })
1974
+ })
1975
+ Object.keys(nameByMid).forEach(function (mid) {
1976
+ if (!seen[mid]) { seen[mid] = 1; c2c.push({ id: mid, name: nameByMid[mid].name, lastSeen: nameByMid[mid].ts, count: 0 }) }
1977
+ })
1978
+ c2c.sort(function (a, b) { return (b.lastSeen || 0) - (a.lastSeen || 0) })
1979
+ state.c2cs = c2c
1980
+ // 期望目标(wantPeer)是私聊 → 自动选中该对象
1981
+ if (state.wantPeer && state.wantPeer.scope === 'c2c') {
1982
+ var hit = null
1983
+ state.c2cs.forEach(function (c) { if (c.id === state.wantPeer.peerId) hit = c })
1984
+ if (hit) { state.sendTo = hit.id; state.sendName = hit.name || '' }
1985
+ else if (state.wantPeer.peerId) { state.sendTo = state.wantPeer.peerId; state.sendName = state.wantPeer.peerId }
1986
+ } else if (!state.sendTo && state.c2cs.length && state.sendScope === 'c2c') {
1987
+ state.sendTo = state.c2cs[0].id; state.sendName = state.c2cs[0].name || ''
1988
+ }
1989
+ paintBody()
1990
+ })
1991
+ }
1992
+ function fmtDeadline(ts) {
1993
+ var left = Math.max(0, Math.ceil((ts - Date.now()) / 1000))
1994
+ return left > 60 ? Math.ceil(left / 60) + ' 分钟' : left + ' 秒'
1995
+ }
1996
+ function groupSelName() {
1997
+ var g = null; state.groups.forEach(function (x) { if (x.gid === state.gid) g = x })
1998
+ return g ? g.name || state.gid.slice(0, 10) : '(未选群)'
1999
+ }
2000
+ function c2cSelName() {
2001
+ if (state.sendName) return state.sendName
2002
+ var c = null; state.c2cs.forEach(function (x) { if (x.id === state.sendTo) c = x })
2003
+ return c ? (c.name || c.id.slice(0, 10)) : '(未选)'
2004
+ }
2005
+ function sendNow() {
2006
+ var t = (state.sendText || '').trim()
2007
+ if (!t) { state.msg = '先输入内容'; paintBody(); return }
2008
+ state.busy = 'send'; paintBody()
2009
+ var body = { text: t, ns: state.ns || undefined, insertContext: state.insertCtx === true }
2010
+ if (state.sendScope === 'c2c') {
2011
+ if (!state.sendTo) { state.msg = '先选私聊对象'; state.busy = ''; paintBody(); return }
2012
+ body.openid = state.sendTo
2013
+ } else {
2014
+ if (!state.gid) { state.msg = '先选群'; state.busy = ''; paintBody(); return }
2015
+ body.gid = state.gid
2016
+ }
2017
+ apiPost(state.sendScope === 'c2c' ? 'chat/send' : 'group/send', body).then(function (d) {
2018
+ state.busy = ''
2019
+ state.msg = (d && (d.msg || (d.err && d.err.human))) || (d && d.error) || '发送结果未知'
2020
+ if (d && d.ok) state.sendText = ''
2021
+ paintBody()
2022
+ })
2023
+ }
2024
+ function loadJoins() {
2025
+ state.joins = null; paintBody()
2026
+ if (!state.gid) { state.joins = []; paintBody(); return }
2027
+ var q = 'gid=' + encodeURIComponent(state.gid) + (state.ns ? '&ns=' + encodeURIComponent(state.ns) : '')
2028
+ api('group/join_requests', q).then(function (d) {
2029
+ state.joins = (d && d.ok && Array.isArray(d.list) ? d.list : []).map(function (j) { return { member_openid: j.member_openid, username: j.username || '(未知昵称)', verify: (j.verify_info && (j.verify_info.verify_message || j.verify_info.method)) || '', risk: j.risk_tips || '', source: j.apply_source === 'invited' ? '被邀请' : '主动申请' } })
2030
+ paintBody()
2031
+ })
2032
+ }
2033
+ function doApprove(mid, op) {
2034
+ var reason = op === 'decline' ? window.prompt('拒绝理由(可留空)') : '-'
2035
+ if (op === 'decline' && reason === null) return
2036
+ state.busy = 'ap-' + mid; paintBody()
2037
+ apiPost('group/approve', { ns: state.ns || undefined, gid: state.gid, member_openid: mid, op: op, reason: reason || '' }).then(function (d) {
2038
+ state.busy = ''
2039
+ state.msg = (d && (d.msg || (d.err && d.err.human))) || '审批结果未知'
2040
+ refreshBadge(); loadJoins()
2041
+ })
2042
+ }
2043
+ function loadMutes() {
2044
+ state.mutes = null; state.members = null; paintBody()
2045
+ if (!state.gid) { state.mutes = []; state.members = []; paintBody(); return }
2046
+ var q = 'gid=' + encodeURIComponent(state.gid) + (state.ns ? '&ns=' + encodeURIComponent(state.ns) : '')
2047
+ api('group/mute_state', q).then(function (d) {
2048
+ var ms = (d && d.ok && d.data && Array.isArray(d.data.members) ? d.data.members : [])
2049
+ state.mutes = ms.map(function (m) { return { member_openid: m.member_openid, username: m.username || '', expire: m.mute_expire_at || '' } })
2050
+ paintBody()
2051
+ })
2052
+ api('group/members_local', q).then(function (d) {
2053
+ state.members = (d && d.ok && Array.isArray(d.members) ? d.members : []).map(function (m) { return { mid: m.mid, name: m.name || '' } })
2054
+ paintBody()
2055
+ })
2056
+ }
2057
+ function doMute(mid, action, seconds) {
2058
+ var secs = action === 'mute' ? Math.max(1, Math.round(Number(seconds || 600))) : 0
2059
+ state.busy = 'm-' + mid; paintBody()
2060
+ apiPost('group/mute', { ns: state.ns || undefined, gid: state.gid, member_openid: mid, action: action, seconds: secs }).then(function (d) {
2061
+ state.busy = ''
2062
+ state.msg = (d && (d.msg || (d.err && d.err.human))) || '操作结果未知'
2063
+ loadMutes(); refreshBadge()
2064
+ })
2065
+ }
2066
+ function doBind() {
2067
+ var g = (state.bindGid || '').trim()
2068
+ if (!g) { state.msg = '先粘贴 group_openid'; paintBody(); return }
2069
+ apiPost('group/bind', { ns: state.ns || undefined, gid: g, name: (state.bindName || '').trim() }).then(function (d) {
2070
+ if (d && d.ok) { state.msg = '已登记群 ✓'; state.bindGid = ''; state.bindName = ''; loadGroups() }
2071
+ else state.msg = (d && d.error) || '绑定失败'
2072
+ paintBody()
2073
+ })
2074
+ }
2075
+
2076
+ function paintHead() {
2077
+ if (!panel || !open) return
2078
+ var h = panel.querySelector('.dk-h')
2079
+ if (!h) return
2080
+ var opts = state.accts.map(function (a) { return '<option value="' + esc(a.ns) + '"' + (a.ns === state.ns ? ' selected' : '') + '>' + esc(a.ns) + (a.appId ? ' (' + a.appId + ')' : '') + (a.disabled ? ' [停用]' : '') + '</option>' }).join('')
2081
+ h.innerHTML = '<span>🛡 QQ 群管理台</span>'
2082
+ + '<select class="qqs-sel" id="dk-ns" style="max-width:220px">' + (opts || '<option value="">无账号(去账号页添加)</option>') + '</select>'
2083
+ + '<span style="flex:1"></span>'
2084
+ + '<span class="dk-msg" id="dk-headmsg" style="color:#888;font-size:12px">直连 QQ 官方 · 面板操作 = 主人直发</span>'
2085
+ + '<button class="dk-btn" id="dk-full" title="全屏/还原">⛶</button>'
2086
+ + '<button class="dk-btn" id="dk-close" title="收回成球">➖</button>'
2087
+ var fb = h.querySelector('#dk-full')
2088
+ if (fb) fb.onclick = function (e) { e.stopPropagation(); var fs = panel.classList.toggle('dk-full'); fb.textContent = fs ? '🗗' : '⛶'; setTimeout(layoutPanel, 0) }
2089
+ h.querySelector('#dk-close').onclick = function () { closePanel(); ball.style.display = 'block' }
2090
+ h.querySelector('#dk-ns').onchange = function (e) {
2091
+ state.ns = e.target.value; state.gid = ''; state.sendTo = ''; state.sendName = ''; state.wantPeer = null; state.msg = ''
2092
+ refreshAll(); paintHead()
2093
+ }
2094
+ }
2095
+ function applyTargetFilter(panel, state) {
2096
+ var q = String(state.targetQ || '').trim().toLowerCase()
2097
+ var scope = state.sendScope
2098
+ var sel = scope === 'c2c' ? panel.querySelector('#dk-c2c') : panel.querySelector('#dk-gid')
2099
+ if (!sel) return
2100
+ var selVal = scope === 'c2c' ? state.sendTo : state.gid
2101
+ var opts = sel.querySelectorAll('option')
2102
+ var shown = 0, total = 0
2103
+ for (var i = 0; i < opts.length; i++) {
2104
+ var o = opts[i]
2105
+ total++
2106
+ var keep = !q || o.textContent.toLowerCase().indexOf(q) >= 0 || o.value === selVal
2107
+ o.hidden = !keep
2108
+ if (keep) shown++
2109
+ }
2110
+ var cnt = panel.querySelector('#dk-target-count')
2111
+ if (cnt) cnt.textContent = q ? ('匹配 ' + shown + '/' + total) : ''
2112
+ }
2113
+ function paintBody() {
2114
+ if (!panel || !open) return
2115
+ var b = panel.querySelector('.dk-b')
2116
+ if (!b) return
2117
+ var groupOpts = state.groups.map(function (g) {
2118
+ var tail = g.gid.length > 8 ? '…' + g.gid.slice(-6) : g.gid
2119
+ var label = g.name ? (g.name + ' (' + tail + ')') : ('群 ' + tail)
2120
+ return '<option value="' + esc(g.gid) + '"' + (g.gid === state.gid ? ' selected' : '') + '>' + esc(label) + (g.from ? ' [' + g.from + ']' : '') + '</option>'
2121
+ }).join('')
2122
+ var groupSelHtml = '<select class="qqs-sel" id="dk-gid" style="min-width:220px">' + (groupOpts || '<option value="">暂无群(可于下方登记)</option>') + '</select>'
2123
+ var c2cOpts = state.c2cs.map(function (c) {
2124
+ var tail = c.id.length > 8 ? '…' + c.id.slice(-6) : c.id
2125
+ var label = c.name ? (c.name + ' (' + tail + ')') : ('私聊 ' + tail)
2126
+ return '<option value="' + esc(c.id) + '"' + (c.id === state.sendTo ? ' selected' : '') + '>' + esc(label) + '</option>'
2127
+ }).join('')
2128
+ var tabs = '<div class="dk-tab">'
2129
+ + '<button data-t="chat" class="' + (state.tab === 'chat' ? 'on' : '') + '">💬 聊天</button>'
2130
+ + '<button data-t="join" class="' + (state.tab === 'join' ? 'on' : '') + '">📥 入群审批<span class="dk-join-badge" style="display:none;background:#ff4d4f;color:#fff;border-radius:8px;font-size:11px;padding:0 5px;margin-left:4px">0</span></button>'
2131
+ + '<button data-t="mute" class="' + (state.tab === 'mute' ? 'on' : '') + '">🔇 禁言</button>'
2132
+ + '</div>'
2133
+ var body = tabs
2134
+ var status = state.msg ? '<div class="dk-msg" style="color:#2f9e44;margin:4px 0">' + esc(state.msg) + '</div>' : ''
2135
+ if (!state.ns || state.accts.length === 0) {
2136
+ body += '<div class="dk-empty">还没有配置机器人账号 → 到「账号与预设」页添加后再回来</div>'
2137
+ } else if (state.tab === 'send') {
2138
+ body += '<div class="dk-row">目标类型: '
2139
+ + '<label style="display:inline-flex;align-items:center;gap:4px"><input type="radio" name="dk-scope" value="group"' + (state.sendScope !== 'c2c' ? ' checked' : '') + '> 群聊</label> '
2140
+ + '<label style="display:inline-flex;align-items:center;gap:4px"><input type="radio" name="dk-scope" value="c2c"' + (state.sendScope === 'c2c' ? ' checked' : '') + '> 私聊</label></div>'
2141
+ body += '<div class="dk-row" style="margin:2px 0 4px">'
2142
+ + '<input class="qqs-txt" id="dk-target-search" placeholder="' + (state.sendScope === 'c2c' ? '🔍 搜私聊对象(昵称/ID)…' : '🔍 搜群(备注/昵称/ID)…') + '" value="' + esc(state.targetQ || '') + '" style="width:100%;box-sizing:border-box;font-size:12px;padding:4px 8px">'
2143
+ + '<span class="dk-msg" id="dk-target-count"></span></div>'
2144
+ var targetSel = state.sendScope === 'c2c'
2145
+ ? '<select class="qqs-sel" id="dk-c2c" style="min-width:220px">' + (c2cOpts || '<option value="">暂无私聊对象</option>') + '</select>'
2146
+ : groupSelHtml
2147
+ body += '<div class="dk-row" id="dk-target-row">' + targetSel + '</div>'
2148
+ body += '<textarea class="qqs-txt" id="dk-text" rows="3" style="width:100%;box-sizing:border-box;margin:6px 0" placeholder="输入内容…(@某人 用 &lt;@对方openid&gt; 无斜杠)">' + esc(state.sendText) + '</textarea>'
2149
+ body += '<div class="dk-row">'
2150
+ + '<label style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#666;cursor:pointer" title="发完后往该会话写入一条「用户代你发送: …」的模拟用户消息(web 流可见、不唤醒、不开回合;标记不会发到 QQ)">'
2151
+ + '<input type="checkbox" id="dk-insctx"' + (state.insertCtx ? ' checked' : '') + '> 🧠 记入 bot 上下文(模拟用户消息·不唤醒)</label>'
2152
+ + '<span style="flex:1"></span>'
2153
+ + '<button class="dk-btn ok" id="dk-send">🚀 发送到 ' + (state.sendScope === 'c2c' ? ('私聊「' + esc(c2cSelName()) + '」') : '群「' + esc(groupSelName()) + '」') + '</button>'
2154
+ + '<span class="dk-msg">已输 ' + state.sendText.length + '/2000</span></div>'
2155
+ } else if (state.tab === 'chat') {
2156
+ body += '<div class="dk-row">目标类型: '
2157
+ + '<label style="display:inline-flex;align-items:center;gap:4px"><input type="radio" name="dk-scope" value="group"' + (state.sendScope !== 'c2c' ? ' checked' : '') + '> 群聊</label> '
2158
+ + '<label style="display:inline-flex;align-items:center;gap:4px"><input type="radio" name="dk-scope" value="c2c"' + (state.sendScope === 'c2c' ? ' checked' : '') + '> 私聊</label></div>'
2159
+ body += '<div class="dk-row" style="margin:2px 0 4px">'
2160
+ + '<input class="qqs-txt" id="dk-target-search" placeholder="' + (state.sendScope === 'c2c' ? '🔍 搜私聊对象(昵称/ID)…' : '🔍 搜群(备注/昵称/ID)…') + '" value="' + esc(state.targetQ || '') + '" style="width:100%;box-sizing:border-box;font-size:12px;padding:4px 8px">'
2161
+ + '<span class="dk-msg" id="dk-target-count"></span></div>'
2162
+ var chatTargetSel = state.sendScope === 'c2c'
2163
+ ? '<select class="qqs-sel" id="dk-c2c" style="min-width:220px">' + (c2cOpts || '<option value="">暂无私聊对象</option>') + '</select>'
2164
+ : groupSelHtml
2165
+ body += '<div class="dk-row" id="dk-target-row">' + chatTargetSel + '</div>'
2166
+ body += '<div class="dk-chat-wrap">'
2167
+ + '<div class="dk-chat-head">'
2168
+ + '<button class="dk-btn ok" id="dk-chat-refresh">🔄 刷新</button>'
2169
+ + '<span class="dk-msg" id="dk-chat-status" style="flex:1">QQ 会话记录(注入/系统文本已滤)</span>'
2170
+ + '</div>'
2171
+ + '<div class="dk-chat-box" id="dk-chat-box"></div>'
2172
+ + '</div>'
2173
+ // QQ 风格输入栏: 文本发送; bbcode [MEDIA:图片|路径/链接] 支持本地图与网络图
2174
+ body += '<div class="dk-composer">'
2175
+ + '<textarea id="dk-chat-input" rows="2" placeholder="输入文字发送; 点 📷插图/📎文件 把本地路径或网络链接变成 [MEDIA:图片|来源] 放进文本框(可拖拽/粘贴图片)">' + esc(state.chatText) + '</textarea>'
2176
+ + '<div class="dk-cbar">'
2177
+ + '<button class="dk-btn" id="dk-chat-ins" title="发送后写一条「用户代你发送」模拟消息进 bot 上下文(web 流可见)">🧠 ' + (state.chatIns ? '记入上下文' : '不记上下文') + '</button>'
2178
+ + '<span class="dk-msg" id="dk-chat-cnt" style="color:#b3b7bd">0/2000</span>'
2179
+ + '<span style="flex:1"></span>'
2180
+ + '<input type="file" id="dk-chat-file" multiple hidden>'
2181
+ + '<button class="dk-btn" id="dk-chat-add-file" title="选择本机文件(小文件自动上传, 大文件请直接输入本机路径)">📎 文件</button>'
2182
+ + '<button class="dk-btn" id="dk-chat-add-img" title="插入图片: 弹窗输入本机路径或网络图片地址 → 自动变成 [MEDIA:图片|来源]">📷 插图</button>'
2183
+ + '<button class="dk-send" id="dk-chat-send">发送</button>'
2184
+ + '</div>'
2185
+ + '</div>'
2186
+ } else if (state.tab === 'join') {
2187
+ body += '<div class="dk-row">目标群: ' + groupSelHtml
2188
+ + '<button class="dk-btn" id="dk-refresh-join">🔄 刷新</button>'
2189
+ + '<span class="dk-msg">机器人需为群管理员; 通过/拒绝直接生效</span></div>'
2190
+ body += '<div class="dk-list" id="dk-join-list"></div>'
2191
+ } else if (state.tab === 'mute') {
2192
+ body += '<div class="dk-row">目标群: ' + groupSelHtml
2193
+ + '<button class="dk-btn" id="dk-refresh-mute">🔄 刷新</button></div>'
2194
+ body += '<div class="dk-msg">全员禁言: 官方接口未开放 · 只能操作普通成员(群主/管理员不可禁)</div>'
2195
+ body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">① 禁言成员(机器人见过的)</div>'
2196
+ body += '<div class="dk-list" id="dk-member-list"></div>'
2197
+ body += '<div style="font-weight:700;font-size:13px;margin:8px 0 4px">② 正在禁言中</div>'
2198
+ body += '<div class="dk-list" id="dk-mute-list"></div>'
2199
+ }
2200
+ body += status
2201
+ body += '<div class="dk-row" style="border-top:1px solid #f0ecff;padding-top:8px;margin-top:8px"><span class="dk-msg">登记群(官方无群列表,手动粘贴):</span>'
2202
+ + '<input class="qqs-txt" id="dk-bindgid" placeholder="group_openid" value="' + esc(state.bindGid) + '" style="width:200px">'
2203
+ + '<input class="qqs-txt" id="dk-bindname" placeholder="备注(可选)" value="' + esc(state.bindName) + '" style="width:110px">'
2204
+ + '<button class="dk-btn" id="dk-bind">绑定</button></div>'
2205
+ b.innerHTML = body
2206
+ bindBodyEvents()
2207
+ bindPanelDrag()
2208
+ if (state.tab === 'send') applyTargetFilter(panel, state)
2209
+ if (state.tab === 'chat') renderChatList()
2210
+ if (state.tab === 'join') renderJoinList()
2211
+ if (state.tab === 'mute') { renderMemberList(); renderMuteList() }
2212
+ // 首次进入聊天 tab 自动拉最新一页
2213
+ if (state.tab === 'chat' && !state.chatItems.length && !state.chatBusy) loadChat(true)
2214
+ setTimeout(layoutPanel, 0)
2215
+ }
2216
+ function bindBodyEvents() {
2217
+ var tbs = panel.querySelectorAll('.dk-tab button')
2218
+ tbs.forEach(function (btn) {
2219
+ btn.onclick = function () {
2220
+ state.tab = btn.getAttribute('data-t'); state.msg = ''
2221
+ if (state.tab === 'join') loadJoins(); else if (state.tab === 'mute') loadMutes()
2222
+ else if (state.tab === 'chat') { state.chatItems = []; state.chatErr = ''; }
2223
+ paintBody()
2224
+ }
2225
+ })
2226
+ var gsel = panel.querySelector('#dk-gid')
2227
+ if (gsel) gsel.onchange = function (e) {
2228
+ state.gid = e.target.value; state.msg = ''
2229
+ if (state.tab === 'join') loadJoins(); else if (state.tab === 'mute') loadMutes()
2230
+ else if (state.tab === 'chat') { state.chatItems = []; state.chatErr = ''; state.chatBusy = ''; paintBody(); loadChat(true) }
2231
+ else paintBody()
2232
+ }
2233
+ var rads = panel.querySelectorAll('input[name="dk-scope"]')
2234
+ rads.forEach(function (r) {
2235
+ r.onchange = function () {
2236
+ state.sendScope = r.value; state.msg = ''
2237
+ if (r.value !== 'c2c') state.sendTo = ''
2238
+ if (state.tab === 'chat') { state.chatItems = []; state.chatErr = ''; state.chatBusy = ''; paintBody(); loadChat(true) }
2239
+ else paintBody()
2240
+ }
2241
+ })
2242
+ var c2c = panel.querySelector('#dk-c2c')
2243
+ if (c2c) c2c.onchange = function (e) {
2244
+ state.sendTo = e.target.value; state.msg = ''
2245
+ if (state.tab === 'chat') { state.chatItems = []; state.chatErr = ''; state.chatBusy = ''; paintBody(); loadChat(true) }
2246
+ else paintBody()
2247
+ }
2248
+ var srch = panel.querySelector('#dk-target-search')
2249
+ if (srch) srch.oninput = function (e) { state.targetQ = e.target.value; applyTargetFilter(panel, state) }
2250
+ var txt = panel.querySelector('#dk-text')
2251
+ if (txt) { txt.oninput = function (e) { state.sendText = e.target.value; var n = panel.querySelector('.dk-msg'); if (n) n.textContent = '已输 ' + state.sendText.length + '/2000' } }
2252
+ var ins = panel.querySelector('#dk-insctx')
2253
+ if (ins) { ins.onchange = function (e) { state.insertCtx = !!e.target.checked } }
2254
+ var snd = panel.querySelector('#dk-send')
2255
+ if (snd) snd.onclick = sendNow
2256
+ var rj = panel.querySelector('#dk-refresh-join')
2257
+ if (rj) rj.onclick = loadJoins
2258
+ var rm = panel.querySelector('#dk-refresh-mute')
2259
+ if (rm) rm.onclick = loadMutes
2260
+ var crf = panel.querySelector('#dk-chat-refresh')
2261
+ if (crf) crf.onclick = function () { state.chatItems = []; state.chatErr = ''; state.chatBusy = ''; loadChat(true) }
2262
+ var cbox = panel.querySelector('#dk-chat-box')
2263
+ if (cbox) cbox.onscroll = function () {
2264
+ if (state.tab !== 'chat' || !state.chatMore || state.chatBusy) return
2265
+ if (cbox.scrollTop <= 4) loadChat(false) // 顶部 → 加载更早
2266
+ }
2267
+ var cinput = panel.querySelector('#dk-chat-input')
2268
+ if (cinput) {
2269
+ cinput.oninput = function (e) { state.chatText = e.target.value; var n = panel.querySelector('#dk-chat-cnt'); if (n) n.textContent = state.chatText.length + '/2000' }
2270
+ cinput.onkeydown = function (e) {
2271
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend('auto') }
2272
+ }
2273
+ // 粘贴/拖拽图片 → 自动转 [MEDIA:kind|来源] 追加进文本框(有本机路径直用, 否则小文件上传到 dock-uploads)
2274
+ cinput.onpaste = function (e) {
2275
+ var files = (e.clipboardData && e.clipboardData.files) ? Array.from(e.clipboardData.files) : []
2276
+ // 截图/复制位图时 clipboardData.files 常为空 → 从 items 里捞图片
2277
+ if (!files.length && e.clipboardData && e.clipboardData.items) {
2278
+ for (var i = 0; i < e.clipboardData.items.length; i++) {
2279
+ var it = e.clipboardData.items[i]
2280
+ if (it.kind === 'file' || String(it.type).indexOf('image/') === 0) {
2281
+ var f = (it.getAsFile && it.getAsFile()) || null
2282
+ if (f) files.push(f)
2283
+ }
2284
+ }
2285
+ }
2286
+ if (files.length) { e.preventDefault(); chatIngestFiles(files) }
2287
+ }
2288
+ cinput.addEventListener('dragover', function (e) { e.preventDefault() })
2289
+ cinput.ondrop = function (e) {
2290
+ e.preventDefault()
2291
+ var files = (e.dataTransfer && e.dataTransfer.files) ? Array.from(e.dataTransfer.files) : []
2292
+ if (files.length) chatIngestFiles(files)
2293
+ }
2294
+ }
2295
+ var cins = panel.querySelector('#dk-chat-ins')
2296
+ if (cins) cins.onclick = function () { state.chatIns = !state.chatIns; cins.textContent = '🧠 ' + (state.chatIns ? '记入上下文' : '不记上下文') }
2297
+ var csend = panel.querySelector('#dk-chat-send')
2298
+ if (csend) csend.onclick = function () { chatSend('auto') }
2299
+ var fadd = panel.querySelector('#dk-chat-add-file')
2300
+ var fpick = panel.querySelector('#dk-chat-file')
2301
+ if (fadd && fpick) fadd.onclick = function () { fpick.value = ''; fpick.click() }
2302
+ if (fpick) fpick.onchange = function () { var files = Array.from(fpick.files || []); if (files.length) chatIngestFiles(files, true) }
2303
+ var iadd = panel.querySelector('#dk-chat-add-img')
2304
+ if (iadd) iadd.onclick = function () {
2305
+ // 弹小窗输入本机路径或网络图片 → 插入 [MEDIA:image|src](追加, 不清空已有文本)
2306
+ var src = window.prompt('输入图片来源: 本机绝对路径(D:\\xxx\\a.jpg) 或 http(s) 图片地址', '')
2307
+ if (src === null) return
2308
+ src = String(src || '').trim()
2309
+ if (!src) return
2310
+ chatInsertMedia('image', src)
2311
+ }
2312
+ var bd = panel.querySelector('#dk-bind')
2313
+ if (bd) bd.onclick = function () { state.bindGid = (panel.querySelector('#dk-bindgid') || {}).value || ''; state.bindName = (panel.querySelector('#dk-bindname') || {}).value || ''; doBind() }
2314
+ }
2315
+ // ── 💬 聊天视图: 数据拉取 / QQ 风格气泡渲染 / 顶部滚动分页 ──
2316
+ function chatPeerReady() {
2317
+ return state.sendScope === 'c2c' ? !!state.sendTo : !!state.gid
2318
+ }
2319
+ function chatPeerName() {
2320
+ return state.sendScope === 'c2c'
2321
+ ? ('私聊「' + (c2cSelName()) + '」')
2322
+ : ('群「' + (groupSelName()) + '」')
2323
+ }
2324
+ // 剥 Markdown 语法(移植自 gal-view transcript.mjs, 保留正文)让气泡像 QQ 纯文本
2325
+ function mdPlain(t) {
2326
+ if (typeof t !== 'string') return ''
2327
+ return t
2328
+ .replace(/^```[^\n]*$/gm, '')
2329
+ .replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '')
2330
+ .replace(/\[([^\]\n]+)\]\([^)\n]*\)/g, '$1')
2331
+ .replace(/\*\*([^*\n]+)\*\*/g, '$1')
2332
+ .replace(/~~([^~\n]+)~~/g, '$1')
2333
+ .replace(/(^|[^*\w])\*([^*\n]+?)\*(?!\*)(?![*\w])/g, '$1$2')
2334
+ .replace(/`([^`\n]+)`/g, '$1')
2335
+ .replace(/^#{1,6}[ \t]+/gm, '')
2336
+ .replace(/^>[ \t]?/gm, '')
2337
+ .replace(/^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/gm, '')
2338
+ .replace(/^[-*+][ \t]+/gm, '')
2339
+ .replace(/^\d+\.[ \t]+/gm, '')
2340
+ .replace(/\r\n?/g, '\n')
2341
+ .replace(/\n{3,}/g, '\n\n')
2342
+ .trim()
2343
+ }
2344
+ function chatTime(ts) {
2345
+ if (!ts) return ''
2346
+ var d = new Date(ts)
2347
+ if (isNaN(d.getTime())) return ''
2348
+ var p2 = function (n) { return String(n).padStart(2, '0') }
2349
+ var hm = p2(d.getHours()) + ':' + p2(d.getMinutes())
2350
+ var now = new Date()
2351
+ if (d.toDateString() === now.toDateString()) return hm
2352
+ return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm
2353
+ }
2354
+ // reset=true 拉最新一页(清空重载); reset=false 用 chatOldest 加载更早并保持视口
2355
+ function loadChat(reset) {
2356
+ if (!state.ns) { renderChatList(); return }
2357
+ if (!chatPeerReady()) { state.chatItems = []; state.chatMore = false; state.chatErr = '先选一个群/私聊目标'; renderChatList(); return }
2358
+ if (state.chatBusy) return
2359
+ var mode = reset ? 'chat' : 'more'
2360
+ state.chatBusy = mode
2361
+ if (reset) { state.chatItems = []; state.chatOldest = 0 }
2362
+ var box = document.getElementById('dk-chat-box')
2363
+ var keep = 0
2364
+ if (box && mode === 'more') keep = box.scrollHeight - box.scrollTop
2365
+ renderChatList() // 立即显示加载态
2366
+ var peerId = state.sendScope === 'c2c' ? state.sendTo : state.gid
2367
+ var q = 'ns=' + encodeURIComponent(state.ns) + '&scope=' + state.sendScope + '&peerId=' + encodeURIComponent(peerId) + '&limit=50'
2368
+ if (mode === 'more' && state.chatOldest > 0) q += '&beforeSeq=' + state.chatOldest
2369
+ api('chat/history', q).then(function (d) {
2370
+ state.chatBusy = ''
2371
+ if (!d || !d.ok) { state.chatErr = (d && (d.error || d.msg)) || '加载失败'; renderChatList(); return }
2372
+ var list = (Array.isArray(d.items) ? d.items : []).filter(function (it) {
2373
+ return it && ((typeof it.text === 'string' && it.text) || (Array.isArray(it.images) && it.images.length))
2374
+ })
2375
+ if (mode === 'more') {
2376
+ var have = {}
2377
+ state.chatItems.forEach(function (x) { have[x.seq] = 1 })
2378
+ state.chatItems = list.filter(function (x) { return !have[x.seq] }).concat(state.chatItems)
2379
+ } else {
2380
+ state.chatItems = list
2381
+ }
2382
+ state.chatMore = d.hasMore === true
2383
+ state.chatErr = ''
2384
+ state.chatOldest = state.chatItems.length ? state.chatItems[0].seq : 0
2385
+ renderChatList(mode === 'more' ? keep : null)
2386
+ }).catch(function () {
2387
+ state.chatBusy = ''
2388
+ state.chatErr = '网络错误'
2389
+ renderChatList()
2390
+ })
2391
+ }
2392
+ function renderChatList(keepScrollOffset) {
2393
+ var box = document.getElementById('dk-chat-box')
2394
+ if (!box) return
2395
+ var st = document.getElementById('dk-chat-status')
2396
+ if (st) {
2397
+ if (state.chatBusy === 'send') st.textContent = '发送中…'
2398
+ else if (chatFlash) st.textContent = chatFlash
2399
+ else if (state.chatBusy) st.textContent = state.chatBusy === 'chat' ? '加载中…' : '加载更早…'
2400
+ else if (state.chatErr) st.textContent = state.chatErr
2401
+ else if (!state.chatItems.length) st.textContent = '暂无记录 —— 机器人和该目标聊过后会显示在这里'
2402
+ else st.textContent = '共 ' + state.chatItems.length + ' 条 · ' + chatPeerName() + (state.chatMore ? ' · 上滑加载更早' : ' · 已到最早')
2403
+ }
2404
+ if (!state.chatItems.length) {
2405
+ var emptyTxt = state.chatErr || (!chatPeerReady() ? '先选一个群/私聊目标' : '还没有聊天记录(该目标暂无活跃会话)')
2406
+ box.innerHTML = '<div class="dk-empty" style="padding:26px 0">' + esc(emptyTxt) + '</div>'
2407
+ return
2408
+ }
2409
+ var html = ''
2410
+ if (state.chatMore) html += '<div class="dk-chat-top">↑ 上滑加载更早消息</div>'
2411
+ html += state.chatItems.map(function (it) {
2412
+ var isOut = it.dir === 'out'
2413
+ var who = isOut ? '我' : (it.sender || (state.sendScope === 'c2c' ? c2cSelName() : '群友'))
2414
+ var ava = isOut ? '🐳' : chatAvaOf(who)
2415
+ var meta = '<span>' + esc(who) + '</span>'
2416
+ + (it.tag ? '<span class="dk-ctag">' + esc(it.tag) + '</span>' : '')
2417
+ + '<span>' + esc(chatTime(it.time)) + '</span>'
2418
+ var imgs = (Array.isArray(it.images) ? it.images : []).map(function (m) {
2419
+ var u = typeof m === 'string' ? m : (m && m.url)
2420
+ var k = typeof m === 'string' ? 'image' : ((m && m.kind) || 'image')
2421
+ if (!u) return ''
2422
+ if (k === 'file') return '<a class="dk-file" href="' + esc(u) + '" target="_blank" rel="noopener" title="' + esc(u) + '">📎 ' + esc((m && m.name) || '文件') + '</a>'
2423
+ if (k === 'voice') return '<audio class="dk-audio" controls preload="metadata" src="' + esc(u) + '"></audio>'
2424
+ if (k === 'video') return '<video class="dk-video" controls preload="metadata" src="' + esc(u) + '"></video>'
2425
+ return '<img class="dk-img" loading="lazy" data-lb="' + esc(u) + '" src="' + esc(u) + '" referrerpolicy="no-referrer" alt="[图片]" onerror="this.outerHTML=\'<span style=color:#888>[图加载失败]</span>\'">'
2426
+ }).join('')
2427
+ var txt = it.text ? '<div>' + esc(mdPlain(it.text)) + '</div>' : ''
2428
+ return '<div class="dk-crow ' + (isOut ? 'out' : 'in') + '">'
2429
+ + '<div class="dk-ava">' + ava + '</div>'
2430
+ + '<div class="dk-cmain">'
2431
+ + '<div class="dk-cmeta">' + meta + '</div>'
2432
+ + '<div class="dk-cbubble">' + imgs + txt + '</div>'
2433
+ + '</div>'
2434
+ + '</div>'
2435
+ }).join('')
2436
+ html += '<div class="dk-chat-bottom">—— 会话尾部 · ' + esc(chatPeerName()) + ' ——</div>'
2437
+ box.innerHTML = html
2438
+ // 图片: 单击/右键 → 灯箱放大(右键不再弹浏览器菜单, 避免误触发 onerror 变加载失败)
2439
+ box.querySelectorAll('img.dk-img').forEach(function (img) {
2440
+ var lb = img.getAttribute('data-lb') || img.src
2441
+ img.onclick = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(lb) }
2442
+ img.oncontextmenu = function (e) { e.preventDefault(); e.stopPropagation(); openLightbox(lb) }
2443
+ })
2444
+ // 滚动: 刷新/切目标 → 滚到底; 上滚加载更早 → 保持视口
2445
+ if (keepScrollOffset == null) box.scrollTop = box.scrollHeight
2446
+ else box.scrollTop = box.scrollHeight - keepScrollOffset
2447
+ }
2448
+ // 头像字符: 群友取昵称首字; 私聊/无名兜底
2449
+ function chatAvaOf(who) {
2450
+ var s = String(who || '').trim()
2451
+ if (!s) return '?'
2452
+ try { return Array.from(s)[0] } catch (e) { return s[0] || '?' }
2453
+ }
2454
+ function setChatStatus(msg) {
2455
+ var st = document.getElementById('dk-chat-status')
2456
+ if (st) st.textContent = msg
2457
+ }
2458
+ // 结果提示: 短暂保留(列表刷新/计数不覆盖), 到期自动还原
2459
+ function flashStatus(msg, ms) {
2460
+ chatFlash = msg
2461
+ setChatStatus(msg)
2462
+ var hold = ms || 3500
2463
+ setTimeout(function () {
2464
+ if (chatFlash === msg) {
2465
+ chatFlash = ''
2466
+ var st = document.getElementById('dk-chat-status')
2467
+ if (st && st.textContent === msg) renderChatList()
2468
+ }
2469
+ }, hold)
2470
+ }
2471
+ function closeLightbox() {
2472
+ var ov = document.getElementById('qqs-lightbox')
2473
+ if (ov) ov.remove()
2474
+ }
2475
+ function openLightbox(url) {
2476
+ closeLightbox()
2477
+ var ov = document.createElement('div')
2478
+ ov.id = 'qqs-lightbox'
2479
+ ov.innerHTML = '<span class="lb-x">✕</span><img src="' + esc(url) + '" referrerpolicy="no-referrer" alt="图片">'
2480
+ document.body.appendChild(ov)
2481
+ ov.onclick = closeLightbox
2482
+ var x = ov.querySelector('.lb-x')
2483
+ if (x) x.onclick = function (e) { e.stopPropagation(); closeLightbox() }
2484
+ window.addEventListener('keydown', function h(e) { if (e.key === 'Escape') { closeLightbox(); window.removeEventListener('keydown', h) } })
2485
+ }
2486
+ function chatUrls(text) {
2487
+ var out = []
2488
+ var re = /https?:\/\/[^\s]+/g
2489
+ var m
2490
+ while ((m = re.exec(String(text || ''))) !== null) {
2491
+ var u = m[0].replace(/[,。、;:,.;:!!??)\]】》>」』]+$/, '')
2492
+ if (u) out.push(u)
2493
+ }
2494
+ return out
2495
+ }
2496
+ function chatKindOfMedia(fullText) {
2497
+ if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(fullText) || /\[(图片|img):/i.test(fullText)) return 'image'
2498
+ if (/\.(mp4|webm|mov|avi)(\?|$)/i.test(fullText) || /\[(视频|video):/i.test(fullText)) return 'video'
2499
+ if (/\.(mp3|wav|amr|silk|m4a|ogg)(\?|$)/i.test(fullText) || /\[(语音|voice|音频):/i.test(fullText)) return 'voice'
2500
+ return 'file'
2501
+ }
2502
+ function chatKindLbl(kind) {
2503
+ return kind === 'image' ? '图片' : kind === 'video' ? '视频' : kind === 'voice' ? '语音' : '文件'
2504
+ }
2505
+ // 把文本框里的 [MEDIA:kind|src] 拆出来(src 可为 http 链接或本机路径), 其余为纯文本段
2506
+ function chatSplitBbcode(t) {
2507
+ var medias = []
2508
+ var texts = []
2509
+ var re = /\[MEDIA:([^\]|]+)\|([^\]]+)\]/gi
2510
+ var m
2511
+ var last = 0
2512
+ while ((m = re.exec(String(t || ''))) !== null) {
2513
+ var pre = t.slice(last, m.index).trim()
2514
+ if (pre) texts.push(pre)
2515
+ medias.push({ kind: String(m[1]).toLowerCase(), src: String(m[2]).trim() })
2516
+ last = m.index + m[0].length
2517
+ }
2518
+ var tail = t.slice(last).trim()
2519
+ if (tail) texts.push(tail)
2520
+ // kind 中文兼容: [MEDIA:图片|…]
2521
+ medias.forEach(function (x) {
2522
+ if (x.kind === '图片' || x.kind === 'img') x.kind = 'image'
2523
+ else if (x.kind === '视频' || x.kind === 'video') x.kind = 'video'
2524
+ else if (x.kind === '语音' || x.kind === 'voice' || x.kind === '音频') x.kind = 'voice'
2525
+ else if (x.kind === '文件' || x.kind === 'file') x.kind = 'file'
2526
+ if (x.kind !== 'image' && x.kind !== 'video' && x.kind !== 'voice' && x.kind !== 'file') x.kind = 'file'
2527
+ })
2528
+ return { medias: medias, texts: texts }
2529
+ }
2530
+ // 发送单条媒体: src 支持 http(s) 或本机绝对路径; 勾了🧠记入上下文时 host 写「用户代发」模拟消息
2531
+ function chatSendMediaOne(kind, src) {
2532
+ var body = { ns: state.ns || undefined, scope: state.sendScope, kind: kind }
2533
+ var peerId = state.sendScope === 'c2c' ? state.sendTo : state.gid
2534
+ if (!peerId) return Promise.resolve({ ok: false, msg: '未选目标' })
2535
+ body.peerId = peerId
2536
+ if (/^https?:\/\//i.test(src)) body.url = src
2537
+ else body.localPath = src
2538
+ body.insertContext = state.chatIns === true
2539
+ body.relayText = src // 完整来源(路径/URL)进「用户代发」上下文, dock 可渲染/可读
2540
+ return apiPost('chat/media', body)
2541
+ }
2542
+ // 媒体来源的短描述(记入上下文用): 取文件名/链接尾
2543
+ function chatMediaDesc(src) {
2544
+ var s = String(src || '')
2545
+ var i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
2546
+ var base = i >= 0 ? s.slice(i + 1) : s
2547
+ base = base.replace(/[?&#].*$/, '')
2548
+ return base || '媒体'
2549
+ }
2550
+ // 发送中 UI: 禁用发送按钮/输入框(防连点重复发送), 按钮显示进度
2551
+ function setSending(on, label) {
2552
+ var btn = document.getElementById('dk-chat-send')
2553
+ if (btn) { btn.disabled = !!on; btn.textContent = on ? (label || '发送中…') : '发送' }
2554
+ var ta = document.getElementById('dk-chat-input')
2555
+ if (ta) ta.disabled = !!on
2556
+ }
2557
+ // 依次发送媒体列表, 全部成功后清空输入并刷新; 部分失败即停并提示
2558
+ function chatSendMediaSeq(list, onDone) {
2559
+ if (!list.length) { if (onDone) onDone(); return }
2560
+ if (state.chatBusy) return // 防重入
2561
+ state.chatBusy = 'send'
2562
+ setSending(true, '发送中…')
2563
+ setChatStatus('正在发送 ' + list.length + ' 个媒体…')
2564
+ renderChatList()
2565
+ var i = 0
2566
+ var step = function () {
2567
+ setSending(true, '发送中 ' + (i + 1) + '/' + list.length)
2568
+ setChatStatus('正在发送' + chatKindLbl(list[i].kind) + ' ' + (i + 1) + '/' + list.length + '…(完成前勿再点发送)')
2569
+ chatSendMediaOne(list[i].kind, list[i].src).then(function (d) {
2570
+ if (!d || !d.ok) {
2571
+ state.chatBusy = ''; setSending(false)
2572
+ flashStatus('发送失败: ' + ((d && (d.msg || d.error)) || '未知') + '(输入已保留, 可改后重发)', 6000)
2573
+ return
2574
+ }
2575
+ i++
2576
+ if (i >= list.length) {
2577
+ state.chatText = ''; renderChatInput(); state.chatBusy = ''; setSending(false)
2578
+ flashStatus('已发送 ' + chatKindLbl(list[0].kind) + (list.length > 1 ? ' ×' + list.length : '') + ' ✓')
2579
+ loadChat(true)
2580
+ if (onDone) onDone()
2581
+ } else step()
2582
+ })
2583
+ }
2584
+ step()
2585
+ }
2586
+ // 发送入口(kindArg 保留兼容): 有 [MEDIA:…] → 逐条发媒体 + 剩余文字; 否则 纯链接自动判 / 纯文本
2587
+ function chatSend(kindArg) {
2588
+ if (state.chatBusy) { setChatStatus('正在发送中, 请稍候…'); return }
2589
+ if (!state.ns) { setChatStatus('先选账号实例(头部下拉)'); return }
2590
+ if (!chatPeerReady()) { setChatStatus('先选一个群/私聊目标'); return }
2591
+ var t = (state.chatText || '').trim()
2592
+ if (!t) { setChatStatus('先输入内容, 或用 📷插图/📎文件 加入媒体'); return }
2593
+ var blk = chatSplitBbcode(t)
2594
+ if (blk.medias.length) {
2595
+ chatSendMediaSeq(blk.medias, function () {
2596
+ if (blk.texts.length && !state.chatBusy) chatSendText(blk.texts.join('\n'))
2597
+ })
2598
+ return
2599
+ }
2600
+ var urls = chatUrls(t)
2601
+ if (!urls.length) { chatSendText(t); return }
2602
+ var kind = chatKindOfMedia(t)
2603
+ // 纯链接: 自动当媒体发; 若有残留描述文字(去掉链接后), 媒体成功后补发
2604
+ var clean = t.replace(/https?:\/\/[^\s]+/g, '').replace(/\[(图片|附件|文件|语音|视频|音频):\s*\]/g, '').replace(/\[Attachment:[^\]]*\]/g, '').trim()
2605
+ chatSendMediaSeq([{ kind: kind, src: urls[0] }], function () { if (clean && !state.chatBusy) chatSendText(clean) })
2606
+ }
2607
+ function chatSendText(t) {
2608
+ if (state.chatBusy) return // 防重入(媒体后补发文本也要等空闲)
2609
+ state.chatBusy = 'send'
2610
+ setSending(true, '发送中…')
2611
+ setChatStatus('正在发送文本…')
2612
+ renderChatList()
2613
+ var body = { text: t, ns: state.ns || undefined, insertContext: state.chatIns === true }
2614
+ if (state.sendScope === 'c2c') body.openid = state.sendTo
2615
+ else body.gid = state.gid
2616
+ apiPost(state.sendScope === 'c2c' ? 'chat/send' : 'group/send', body).then(function (d) {
2617
+ state.chatBusy = ''
2618
+ setSending(false)
2619
+ if (d && d.ok) { state.chatText = ''; renderChatInput(); flashStatus(d.msg || '已发送 ✓'); loadChat(true) }
2620
+ else flashStatus((d && (d.msg || (d.err && d.err.human) || d.error)) || '发送结果未知', 6000)
2621
+ })
2622
+ }
2623
+ function chatKindOfFile(f) {
2624
+ var t = String((f && f.type) || '').toLowerCase()
2625
+ var n = String((f && f.name) || '').toLowerCase()
2626
+ if (t.indexOf('image/') === 0 || /\.(jpe?g|png|gif|webp|bmp)$/.test(n)) return 'image'
2627
+ if (t.indexOf('video/') === 0 || /\.(mp4|webm|mov|avi)$/.test(n)) return 'video'
2628
+ if (t.indexOf('audio/') === 0 || /\.(mp3|wav|amr|silk|m4a|ogg)$/.test(n)) return 'voice'
2629
+ return 'file'
2630
+ }
2631
+ function chatFileExt(f) {
2632
+ var n = String((f && f.name) || '')
2633
+ var i = n.lastIndexOf('.')
2634
+ var e = i >= 0 ? n.slice(i + 1).toLowerCase() : ''
2635
+ if (/^[a-z0-9]{1,6}$/.test(e)) return e
2636
+ return 'bin'
2637
+ }
2638
+ // 把 [MEDIA:kind|src] 追加进文本框(不清空已有内容)
2639
+ function chatInsertMedia(kind, src) {
2640
+ var cur = state.chatText || ''
2641
+ if (cur && !/\n$/.test(cur)) cur += '\n'
2642
+ state.chatText = cur + '[MEDIA:' + chatKindLbl(kind) + '|' + src + ']'
2643
+ renderChatInput()
2644
+ var ta = document.getElementById('dk-chat-input')
2645
+ if (ta) { ta.focus(); ta.scrollTop = ta.scrollHeight }
2646
+ setChatStatus('已插入 ' + chatKindLbl(kind) + ', 点发送即可发出')
2647
+ }
2648
+ // 处理选中的文件: 有本机路径(Electron 场景)直接用; 否则 base64 上传到 bot 临时目录拿路径
2649
+ function chatIngestFiles(files, fromPicker) {
2650
+ var list = Array.isArray(files) ? files : []
2651
+ if (!list.length) return
2652
+ var total = list.length
2653
+ var done = 0
2654
+ var finish = function () { done++; if (fromPicker && done >= total) { var fp = document.getElementById('dk-chat-file'); if (fp) fp.value = '' } }
2655
+ list.forEach(function (f) {
2656
+ var kind = chatKindOfFile(f)
2657
+ var directPath = ''
2658
+ try { if (f.path && /^[a-zA-Z]:[\\/]/.test(String(f.path))) directPath = String(f.path) } catch (e) { directPath = '' }
2659
+ if (directPath) { chatInsertMedia(kind, directPath); finish(); return }
2660
+ if (f.size > 300 * 1024 * 1024) { setChatStatus('「' + (f.name || '文件') + '」超过 300MB 上限, 请手动输入本机路径'); finish(); return }
2661
+ // 一律走流式上传(不限 ~5MB): 浏览器把 File 直接当 body 传, host 写盘后返回本机路径
2662
+ var ext = chatFileExt(f)
2663
+ setChatStatus('正在上传 ' + (f.name || '文件') + '…')
2664
+ var q = 'ns=' + encodeURIComponent(state.ns || '') + '&ext=' + encodeURIComponent(ext)
2665
+ fetch('/api/qqbot-settings/chat/upload-raw?' + q, {
2666
+ method: 'POST',
2667
+ headers: { 'content-type': 'application/octet-stream' },
2668
+ body: f,
2669
+ }).then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
2670
+ if (d && d.ok && d.path) { chatInsertMedia(kind, d.path); setChatStatus('已上传 ' + (f.name || '文件') + ', 点发送即可发出') }
2671
+ else setChatStatus('上传失败: ' + ((d && (d.error || d.msg)) || '未知'))
2672
+ finish()
2673
+ }).catch(function () { setChatStatus('上传失败(网络错误)'); finish() })
2674
+ })
2675
+ }
2676
+ function renderChatInput() {
2677
+ var ta = document.getElementById('dk-chat-input')
2678
+ if (ta) ta.value = state.chatText || ''
2679
+ var n = document.getElementById('dk-chat-cnt')
2680
+ if (n) n.textContent = (state.chatText || '').length + '/2000'
2681
+ }
2682
+ function renderJoinList() {
2683
+ var box = panel.querySelector('#dk-join-list')
2684
+ if (!box) return
2685
+ var badgeEl = panel.querySelector('.dk-join-badge')
2686
+ if (state.joins === null) { box.innerHTML = '<div class="dk-empty">加载中…</div>'; return }
2687
+ if (state.joins.length === 0) { box.innerHTML = '<div class="dk-empty">当前没有待审批的入群申请 ✓</div>'; return }
2688
+ box.innerHTML = state.joins.map(function (j) {
2689
+ var busying = state.busy === 'ap-' + j.member_openid
2690
+ return '<div class="dk-item">'
2691
+ + '<span style="font-weight:600">' + esc(j.username) + '</span>'
2692
+ + '<span style="color:#999;font-size:12px">' + esc(j.source) + '</span>'
2693
+ + (j.verify ? '<span style="color:#666;font-size:12px">验证: ' + esc(j.verify) + '</span>' : '')
2694
+ + (j.risk ? '<span style="color:#c23131;font-size:12px">⚠ ' + esc(j.risk) + '</span>' : '')
2695
+ + '<span style="flex:1"></span>'
2696
+ + '<button class="dk-btn ok" data-ap="approve" data-mid="' + esc(j.member_openid) + '"' + (busying ? ' disabled' : '') + '>' + (busying ? '处理中…' : '通过') + '</button>'
2697
+ + '<button class="dk-btn no" data-ap="decline" data-mid="' + esc(j.member_openid) + '"' + (busying ? ' disabled' : '') + '>拒绝</button>'
2698
+ + '</div>'
2699
+ }).join('')
2700
+ box.querySelectorAll('button[data-ap]').forEach(function (btn) {
2701
+ btn.onclick = function () { doApprove(btn.getAttribute('data-mid'), btn.getAttribute('data-ap')) }
2702
+ })
2703
+ if (badgeEl) { badgeEl.style.display = 'inline-block'; badgeEl.textContent = state.joins.length }
2704
+ }
2705
+ function renderMemberList() {
2706
+ var box = panel.querySelector('#dk-member-list')
2707
+ if (!box) return
2708
+ if (state.members === null) { box.innerHTML = '<div class="dk-empty">加载中…</div>'; return }
2709
+ if (state.members.length === 0) { box.innerHTML = '<div class="dk-empty">暂无成员记录 —— 群友发言后自动出现(官方成员列表未开放)</div>'; return }
2710
+ box.innerHTML = state.members.map(function (m) {
2711
+ var busying = state.busy === 'm-' + m.mid
2712
+ return '<div class="dk-item">'
2713
+ + '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(m.name || '(未知名)') + ' · ' + esc(String(m.mid).slice(0, 10)) + '…</span>'
2714
+ + '<input class="qqs-txt" data-min="' + esc(m.mid) + '" type="number" min="1" value="' + esc(state.muteSecs) + '" style="width:64px">'
2715
+ + '<span style="color:#999;font-size:12px">分钟</span>'
2716
+ + '<button class="dk-btn no" data-mute="' + esc(m.mid) + '"' + (busying ? ' disabled' : '') + '>' + (busying ? '处理中…' : '禁言') + '</button>'
2717
+ + '</div>'
2718
+ }).join('')
2719
+ box.querySelectorAll('input[data-min]').forEach(function (inp) {
2720
+ inp.oninput = function () { state.muteSecs = inp.value }
2721
+ })
2722
+ box.querySelectorAll('button[data-mute]').forEach(function (btn) {
2723
+ btn.onclick = function () { doMute(btn.getAttribute('data-mute'), 'mute', Number(state.muteSecs || 60) * 60) }
2724
+ })
2725
+ }
2726
+ function renderMuteList() {
2727
+ var box = panel.querySelector('#dk-mute-list')
2728
+ if (!box) return
2729
+ if (state.mutes === null) { box.innerHTML = '<div class="dk-empty">加载中…</div>'; return }
2730
+ if (state.mutes.length === 0) { box.innerHTML = '<div class="dk-empty">没有正在禁言的成员</div>'; return }
2731
+ box.innerHTML = state.mutes.map(function (m) {
2732
+ var busying = state.busy === 'm-' + m.member_openid
2733
+ return '<div class="dk-item">'
2734
+ + '<span style="flex:1">' + esc(m.username || String(m.member_openid).slice(0, 12)) + '</span>'
2735
+ + '<span style="color:#999;font-size:12px">至 ' + esc(String(m.expire || '').replace('T', ' ').slice(0, 16)) + '</span>'
2736
+ + '<button class="dk-btn" data-um="' + esc(m.member_openid) + '"' + (busying ? ' disabled' : '') + '>' + (busying ? '处理中…' : '解除') + '</button>'
2737
+ + '</div>'
2738
+ }).join('')
2739
+ box.querySelectorAll('button[data-um]').forEach(function (btn) {
2740
+ btn.onclick = function () { doMute(btn.getAttribute('data-um'), 'unmute') }
2741
+ })
2742
+ }
2743
+ function renderPanel() {
2744
+ panel.innerHTML = '<div class="dk-h"></div><div class="dk-detect"></div><div class="dk-b"></div>'
2745
+ paintHead(); paintDetect(); paintBody()
2746
+ if (state.tab === 'join') loadJoins()
2747
+ // 每次展开都重查当前 web 会话(跟随主人切换会话; 不打断已有手动选择)
2748
+ if (sessionsSvc || true) { reDetectAndSelect() }
2749
+ }
2750
+ // 检测状态条: 显示 web 当前会话的 sessionId 与反查结果(悬浮球调试/自动选中依据)
2751
+ function paintDetect() {
2752
+ if (!panel || !open) return
2753
+ var d = panel.querySelector('.dk-detect')
2754
+ if (!d) return
2755
+ var det = state.detected
2756
+ var sids = (det && det.sid) || ''
2757
+ var srcLbl = det ? (det.src === 'sessions.list' ? 'sessions.list' : det.src === 'localStorage' ? 'localStorage' : '未检测到') : '检测中…'
2758
+ var hit = state.detectedHit
2759
+ var hitTxt = '未命中 QQ 会话'
2760
+ if (hit) {
2761
+ hitTxt = (hit.scope === 'group' ? '群「' : '私聊「') + esc((hit.name || '') + '」' + (hit.ns ? ' [' + hit.ns + ']' : '')) + (hit.peerId ? ' · ' + hit.peerId.slice(0, 10) + '…' : '')
2762
+ }
2763
+ d.innerHTML = '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:6px 14px;background:#f4f0ff;border-bottom:1px solid #efe8ff;font-size:12px;color:#4a3a9f">'
2764
+ + '<span style="font-weight:700">📡 当前会话</span>'
2765
+ + '<span style="color:#666">来源:' + esc(srcLbl) + '</span>'
2766
+ + (sids ? '<code style="font-size:11px;background:#fff;border:1px solid #e3d9ff;border-radius:5px;padding:1px 6px">' + esc(sids.slice(0, 36)) + (sids.length > 36 ? '…' : '') + '</code>' : '<span style="color:#999">(无)</span>')
2767
+ + '<span style="color:#2f9e44">→ ' + hitTxt + '</span>'
2768
+ + '<button class="dk-btn" id="dk-redetect" style="padding:1px 8px;font-size:11px;margin-left:auto">🔄 重查</button>'
2769
+ + '</div>'
2770
+ var btn = d.querySelector('#dk-redetect')
2771
+ if (btn) btn.onclick = function () { reDetectAndSelect() }
2772
+ }
2773
+ // 重新检测当前 web 会话并自动选中对应 QQ 目标
2774
+ function reDetectAndSelect() {
2775
+ state.detected = null; state.detectedHit = null
2776
+ state.wantPeer = null
2777
+ paintDetect()
2778
+ lookupCurrentSession(function (hit) {
2779
+ if (!state.accts.length) { api('accounts').then(function (dd) { var list = (dd && Array.isArray(dd.instances) ? dd.instances : []).filter(function (a) { return !a.disabled }); state.accts = list; applyHit(hit, list) }) }
2780
+ else applyHit(hit, state.accts)
2781
+ })
2782
+ }
2783
+ function applyHit(hit, list) {
2784
+ if (hit && list.length) {
2785
+ state.ns = hit.ns || list[0].ns || ''
2786
+ state.wantPeer = { scope: hit.scope, peerId: hit.peerId }
2787
+ if (hit.scope === 'c2c') { state.sendScope = 'c2c'; state.sendTo = hit.peerId; state.sendName = hit.name || '' }
2788
+ else { state.sendScope = 'group'; state.gid = hit.peerId }
2789
+ } else if (list.length && !state.ns) {
2790
+ state.ns = list[0].ns || ''
2791
+ }
2792
+ refreshAll(); paintHead(); paintDetect()
2793
+ }
2794
+ loadAccts()
2795
+ // 首次自动展开?不——保持球形态(审批/提问仍走打断弹出)。主人点开才展开。
2796
+ closePanel()
2797
+ }
2798
+
1448
2799
  function apply(ctx) {
1449
2800
  ensureCss()
1450
2801
  var slots = ctx.slots
@@ -1454,9 +2805,15 @@ var QQS_CSS = ".qqs-btn{font:inherit;color:#333;background:linear-gradient(180de
1454
2805
  { name: 'settings.section', id: 'qqbot', order: 32, label: 'QQ 机器人' },
1455
2806
  function (props) { return h(QqbotHome, { close: props && props.close }) })
1456
2807
  })
2808
+ startApprovalFloat()
2809
+ // sessions 服务(宿主根服务): 权威读 web 当前会话(current)与 cwd(agent id === session id)
2810
+ var sessionsSvc = null
2811
+ try { sessionsSvc = (ctx && (ctx.sessions || (ctx.get && ctx.get('sessions')))) || null } catch (e) { sessionsSvc = null }
2812
+ if (!sessionsSvc) { try { sessionsSvc = ctx.get && ctx.get('sessions') } catch (e) {} }
2813
+ startQqDock(sessionsSvc)
1457
2814
  }
1458
2815
 
1459
- exports.inject = ['slots']
2816
+ exports.inject = ['slots', 'sessions']
1460
2817
  exports.apply = apply
1461
2818
  return module.exports
1462
2819
  }